coxpit 6.1.0 → 6.3.0
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/package.json +1 -1
- package/src/board.ts +118 -71
- package/src/cockpit.ts +548 -52
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +4 -0
- package/src/humanize.ts +62 -0
- package/src/orchestrator.ts +57 -14
- package/src/server.ts +18 -9
package/src/cockpit.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Cockpit — 터미널 우선 셸 (병행 개발, /cockpit). board.ts 처럼 자가완결 단일 HTML(빌드 0).
|
|
2
2
|
// 백엔드(server 라우트·term.ts·orchestrator)는 보드와 전부 공유. Phase 5에서 데스크톱 기본을 여기로 플립.
|
|
3
3
|
// Phase 2 = 워크스페이스 트리(/api/fleet 라이브) + 페인 그리드 터미널(오토타일=창분할, 각 페인 /ws/term attach).
|
|
4
|
+
import { HUMANIZE_JS } from './humanize';
|
|
5
|
+
|
|
4
6
|
export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
5
7
|
<html lang="en">
|
|
6
8
|
<head>
|
|
@@ -293,6 +295,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
293
295
|
.pick-row .ic{width:14px;text-align:center;color:var(--faint)}
|
|
294
296
|
.pick-row.up .ic{color:var(--muted)}
|
|
295
297
|
.pick-row .rp{margin-left:auto;font-size:9px;color:var(--brand);border:1px solid rgba(78,201,176,.3);border-radius:999px;padding:0 6px}
|
|
298
|
+
/* v5.28 G5 — 폴더 행 안의 인라인 액션(Register · Start here). 보드의 .btn 을 끌어오지 않는다(토큰만). */
|
|
299
|
+
.pick-row .pbtn{margin-left:auto;flex:none;font-family:var(--mono);font-size:10.5px;line-height:1;color:var(--muted);
|
|
300
|
+
background:none;border:1px solid var(--line-hi);border-radius:5px;padding:4px 7px;cursor:pointer}
|
|
301
|
+
.pick-row .rp+.pbtn{margin-left:8px}
|
|
302
|
+
.pick-row .pbtn:hover{color:var(--brand);background:var(--surface2)}
|
|
296
303
|
.pick-f{display:flex;align-items:center;gap:10px;padding:12px 15px;border-top:1px solid var(--line)}
|
|
297
304
|
.pick-f .go{margin-left:auto;font-family:var(--mono);font-size:12px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:9px 15px;cursor:pointer}
|
|
298
305
|
.pick-f .home{font-family:var(--mono);font-size:11px;color:var(--muted);background:none;border:1px solid var(--line);border-radius:7px;padding:7px 11px;cursor:pointer}
|
|
@@ -322,6 +329,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
322
329
|
.leaf-h .pane-act{font-family:var(--mono);font-size:10px;color:var(--brand);opacity:.9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px}
|
|
323
330
|
.leaf-h .pane-act:empty{display:none}
|
|
324
331
|
.tnode.run .ract{margin-left:6px;font-size:10px;color:var(--brand);opacity:.85}
|
|
332
|
+
/* v5.28 H2 — dry(모의) run 표식. 낱말 하나("dry")를 기존 작은 칩 모양에 얹을 뿐,
|
|
333
|
+
색은 하나도 새로 만들지 않는다(--faint 글자 + --line-hi 테두리). 진짜 run 에는 아무것도 안 붙는다. */
|
|
334
|
+
.dryc{flex:none;font-family:var(--mono);font-size:9px;line-height:1.7;letter-spacing:.04em;
|
|
335
|
+
color:var(--faint);border:1px solid var(--line-hi);border-radius:4px;padding:0 4px;white-space:nowrap}
|
|
325
336
|
.leaf-h .zoom{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
|
|
326
337
|
.leaf-h .zoom:hover{color:var(--brand)}
|
|
327
338
|
.leaf-h .pact{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
|
|
@@ -335,23 +346,77 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
335
346
|
.leaf-h .qr{display:inline-flex;align-items:center;gap:3px;flex:none}
|
|
336
347
|
.leaf-h .qr:empty{display:none}
|
|
337
348
|
/* 빠른 답은 대기의 색(--blocked)을 빌린다 — 칩·미머지 표식과 같은 주의색, 새 색 없음 */
|
|
338
|
-
.
|
|
349
|
+
/* (v5.28 E — 같은 알약이 활동 페인 액션바에도 그대로 선다. 규칙은 안 바꾸고 선택자만 넓힌다) */
|
|
350
|
+
.act-bar .qbtn,.leaf-h .qbtn{font-family:var(--mono);font-size:10px;color:var(--blocked);background:none;
|
|
339
351
|
border:1px solid var(--line-hi);border-radius:999px;padding:1px 7px;cursor:pointer;white-space:nowrap}
|
|
340
|
-
.leaf-h .qbtn:hover,.leaf-h .qbtn:focus-visible{color:var(--ink);border-color:var(--blocked)}
|
|
341
|
-
.leaf-h .qbtn.qadd{color:var(--faint);padding:1px 6px}
|
|
352
|
+
.act-bar .qbtn:hover,.act-bar .qbtn:focus-visible,.leaf-h .qbtn:hover,.leaf-h .qbtn:focus-visible{color:var(--ink);border-color:var(--blocked)}
|
|
353
|
+
.act-bar .qbtn.qadd,.leaf-h .qbtn.qadd{color:var(--faint);padding:1px 6px}
|
|
342
354
|
/* 시작·이어서는 부름이 아니라 제안이라 더 조용하다(--muted 위 --line) */
|
|
343
355
|
.leaf-h .sbtn{font-family:var(--mono);font-size:10px;color:var(--muted);background:none;
|
|
344
356
|
border:1px solid var(--line);border-radius:999px;padding:1px 7px;cursor:pointer;white-space:nowrap}
|
|
345
357
|
.leaf-h .sbtn:hover,.leaf-h .sbtn:focus-visible{color:var(--brand);border-color:var(--line-hi)}
|
|
358
|
+
/* ── v5.28 E — 활동 페인(Activity) ──
|
|
359
|
+
에이전트 run 을 누르면 빈 worktree 셸이 아니라 **지금 하고 있는 일**이 열린다.
|
|
360
|
+
읽는 자리라 조용하다: 색을 갖는 곳은 상태 칩과 "지금" 한 줄뿐이고, 둘 다 이미 있는 토큰이다.
|
|
361
|
+
터미널은 사라지지 않는다 — 액션바 첫 버튼이 그 자리로 되돌린다(E4). */
|
|
362
|
+
.act-host{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column;background:var(--bg);font-family:var(--mono);font-size:11.5px}
|
|
363
|
+
.act-h{display:flex;align-items:center;gap:8px;flex:none;padding:9px 12px 8px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
|
364
|
+
.act-h .anm{color:var(--ink);font-size:12.5px;white-space:nowrap}
|
|
365
|
+
.act-h .apath{color:var(--faint);flex:1;min-width:60px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
366
|
+
.act-h .ameta{color:var(--faint);font-size:10.5px;white-space:nowrap}
|
|
367
|
+
/* 상태 칩 — Part A 의 네 상태를 **같은 토큰**으로(working/waiting/idle/exited). 새 색 없음.
|
|
368
|
+
상태가 없으면 :empty 가 자리를 지운다 — 없는 상태를 지어내지 않는다는 규칙의 표면 쪽 절반. */
|
|
369
|
+
.act-chip{font-size:9px;text-transform:uppercase;letter-spacing:.04em;padding:1px 7px;border-radius:999px;
|
|
370
|
+
border:1px solid currentColor;white-space:nowrap}
|
|
371
|
+
.act-chip:empty{display:none}
|
|
372
|
+
.act-chip.as-working{color:var(--running)}
|
|
373
|
+
.act-chip.as-waiting{color:var(--blocked)}
|
|
374
|
+
.act-chip.as-idle{color:var(--faint)}
|
|
375
|
+
.act-chip.as-exited{color:var(--done)}
|
|
376
|
+
/* "지금" 한 줄 — 가장 알고 싶은 답이라 이 페인에서 제일 크다 */
|
|
377
|
+
.act-now{flex:none;padding:10px 12px;color:var(--brand);font-size:15px;border-bottom:1px solid var(--line);
|
|
378
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
379
|
+
.act-tlwrap{flex:1;min-height:0;position:relative;display:flex}
|
|
380
|
+
.act-tl{flex:1;min-height:0;overflow:auto;padding:8px 12px;line-height:1.55}
|
|
381
|
+
.act-l{display:flex;gap:9px;padding:1px 0}
|
|
382
|
+
.act-l .ak{color:var(--faint);flex:none;min-width:44px;text-align:right}
|
|
383
|
+
.act-l .at{color:var(--muted);white-space:pre-wrap;word-break:break-word;min-width:0}
|
|
384
|
+
.act-empty{color:var(--faint);padding:8px 12px}
|
|
385
|
+
/* 꼬리에 붙어 있을 때만 따라 내려간다 — 위로 올렸으면 멈추고, 돌아갈 길을 하나 띄운다 */
|
|
386
|
+
.act-jump{position:absolute;right:16px;bottom:10px;font-family:var(--mono);font-size:10px;
|
|
387
|
+
color:var(--brand-ink);background:var(--brand);border:none;border-radius:999px;padding:3px 10px;cursor:pointer}
|
|
388
|
+
.act-jump[hidden]{display:none}
|
|
389
|
+
.act-chg{flex:none;border-top:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
|
|
390
|
+
.act-chg-h{display:flex;align-items:center;gap:8px;padding:6px 12px;color:var(--muted)}
|
|
391
|
+
.act-chg-h .afiles{color:var(--faint)}
|
|
392
|
+
.act-chg .rv-diff{max-height:210px;border-top:1px solid var(--line)}
|
|
393
|
+
.act-bar{flex:none;display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 12px;
|
|
394
|
+
border-top:1px solid var(--line);background:var(--surface)}
|
|
395
|
+
/* .abtn = 활동 뷰 버튼. 툴바 .tc-btn 과 같은 치수(radius 6·pad 3/8)로 맞춘다. 셀렉터는 bare —
|
|
396
|
+
.act-chg-h 의 '변경' 버튼도 같은 규칙을 받아야 한다(전엔 .act-bar 스코프라 그 버튼이 흰 네이티브였다). */
|
|
397
|
+
.abtn{font-family:var(--mono);font-size:11px;color:var(--muted);background:none;
|
|
398
|
+
border:1px solid var(--line);border-radius:6px;padding:3px 8px;cursor:pointer;white-space:nowrap}
|
|
399
|
+
.abtn:hover:not([disabled]),.abtn:focus-visible{color:var(--ink);border-color:var(--line-hi)}
|
|
400
|
+
.abtn.prim{color:var(--brand-ink);background:var(--brand);border-color:var(--brand)}
|
|
401
|
+
.act-bar .qr{display:inline-flex;align-items:center;gap:3px}
|
|
402
|
+
.act-bar .qr:empty{display:none}
|
|
403
|
+
/* Activity | Terminal — 요청바의 .modes/.mode 세그를 페인 헤더 크기로만 줄여 쓴다(새 컴포넌트 없음) */
|
|
404
|
+
.leaf-h .modes{padding:1px;border-radius:6px;flex:none}
|
|
405
|
+
.leaf-h .mode{font-size:9.5px;padding:1px 6px}
|
|
346
406
|
.leaf-h .bsel{display:none;color:var(--faint);border:none;background:none;cursor:pointer;font-size:11px;padding:0 2px}
|
|
347
407
|
body.bcastmode .leaf-h .bsel{display:inline-block} /* 브로드캐스트 모드에서만 대상 선택 토글 노출 */
|
|
348
408
|
.leaf-h .bsel.on{color:var(--open)}
|
|
349
409
|
.leaf-h .lock{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
|
|
350
410
|
.leaf-h .lock:hover{color:var(--brand)}
|
|
351
411
|
.leaf-h .sendkey{font:inherit;font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:1px 6px;width:150px;outline:none}
|
|
352
|
-
.
|
|
353
|
-
|
|
354
|
-
.lbl .lacts{display:flex;align-items:center;gap:10px}
|
|
412
|
+
/* 섹션 라벨에 액션이 둘 이상이면 한 묶음으로 — 양쪽 끝으로 흩어지지 않게(.tact+.tact 와 같은 이유).
|
|
413
|
+
min-width:0 로 flex 아이템이 필요하면 줄어들 수 있게(넘칠 땐 각 라벨은 nowrap 이라 통째로 유지) */
|
|
414
|
+
.lbl .lacts{display:flex;align-items:center;gap:10px;min-width:0}
|
|
415
|
+
/* v5.28 F1/G — 섹션 헤더의 ⋯. repo 행 .tmore 와 같은 모양·같은 메뉴(#rowMenu)를 쓴다.
|
|
416
|
+
헤더는 이름과 ⋯ 하나뿐이고(Workspace·Scratch·Projects 모두), 액션은 그 뒤에 산다 */
|
|
417
|
+
.lbl .tmore{font-family:var(--mono);font-size:10.5px;line-height:1;color:var(--faint);
|
|
418
|
+
background:none;border:none;border-radius:5px;padding:3px 5px;cursor:pointer}
|
|
419
|
+
.lbl .tmore:hover,.lbl .tmore[aria-expanded="true"]{color:var(--brand);background:var(--surface2)}
|
|
355
420
|
/* v6.0 S1 — Scratch 안내문은 제거함(의뢰자 요청 2026-09-17). .tree-note 미사용 */
|
|
356
421
|
.tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
|
|
357
422
|
.tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
|
|
@@ -524,8 +589,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
524
589
|
<div class="right">
|
|
525
590
|
<span class="ver" id="ver" title="로드된 cockpit 버전 (캐시 확인용)">v__COXPIT_VER__</span>
|
|
526
591
|
<button type="button" class="waitchip" id="waitChip" hidden title="입력을 기다리는 에이전트 — 클릭하면 차례로 그 터미널로">◔ <span id="waitN">0</span></button>
|
|
592
|
+
<span class="ws" id="ws"><span class="dot"></span><span id="wstext" class="b-txt">connecting</span></span>
|
|
527
593
|
<div class="apwrap" id="apWrap">
|
|
528
|
-
<button type="button" class="toggle" id="
|
|
594
|
+
<button type="button" class="toggle" id="topMore" data-menu="top" aria-haspopup="menu" aria-expanded="false" title="더 보기 — 시크릿(env 주입) · 알림">⋯</button>
|
|
529
595
|
<div class="apop" id="attnPop" hidden role="group" aria-label="알림">
|
|
530
596
|
<div class="lbl"><span>알림</span></div>
|
|
531
597
|
<label class="rchk" title="대기·완료 전이에 짧은 신호음"><input type="checkbox" id="attnSound" /> 소리</label>
|
|
@@ -538,8 +604,6 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
538
604
|
<div class="apop-note">전부 기본 꺼짐. 보고 있는 탭은 자신을 울리지 않습니다.</div>
|
|
539
605
|
</div>
|
|
540
606
|
</div>
|
|
541
|
-
<span class="ws" id="ws"><span class="dot"></span><span id="wstext" class="b-txt">connecting</span></span>
|
|
542
|
-
<button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입">∗<span class="b-txt"> Secrets</span></button>
|
|
543
607
|
<a class="toggle" href="/" title="보드(모니터) 뷰로">←<span class="b-txt"> Board</span></a>
|
|
544
608
|
</div>
|
|
545
609
|
</header>
|
|
@@ -547,7 +611,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
547
611
|
<div class="scrim" id="scrim"></div>
|
|
548
612
|
<div class="layout" id="layout">
|
|
549
613
|
<aside class="rail" id="rail">
|
|
550
|
-
<div class="lbl"><span>Workspace</span><span class="lacts"><
|
|
614
|
+
<div class="lbl"><span>Workspace</span><span class="lacts"><button type="button" class="tmore" id="wsMore" data-menu="ws" aria-haspopup="menu" aria-expanded="false" title="유지보수 — worktree 회수 · 고아 tmux 세션 정리">⋯</button></span></div>
|
|
551
615
|
<div id="tree"></div>
|
|
552
616
|
<div class="railfoot">클릭한 run·세션은 <b>탭</b>으로 열립니다 · split 으로 페인을 나란히 배치</div>
|
|
553
617
|
</aside>
|
|
@@ -556,13 +620,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
556
620
|
<div class="tabbar">
|
|
557
621
|
<div class="tabs" id="tabs"></div>
|
|
558
622
|
<div class="tabctl">
|
|
559
|
-
<button class="tc-btn" id="splitRow" title="세로 분할 — 포커스 페인을 좌우로" disabled
|
|
560
|
-
<button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled
|
|
623
|
+
<button class="tc-btn" id="splitRow" title="세로 분할 — 포커스 페인을 좌우로" disabled>▐<span class="b-txt"> Split</span></button>
|
|
624
|
+
<button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled>▬<span class="b-txt"> Split</span></button>
|
|
561
625
|
<button class="tc-btn session" id="sessionBtn" title="자유 세션(폴더 지정 터미널) 열기">+<span class="b-txt"> Session</span></button>
|
|
562
626
|
<button class="tc-btn" id="fileBtn" title="파일 보기 — md·html·pdf·이미지·텍스트 뷰어(터미널 옆 페인)">▤<span class="b-txt"> File</span></button>
|
|
563
|
-
<button class="tc-btn" id="attachBtn" title="파일 첨부 — 포커스한 터미널 폴더로 업로드 + 경로 삽입 (드롭도 가능)">↥<span class="b-txt">
|
|
627
|
+
<button class="tc-btn" id="attachBtn" title="파일 첨부 — 포커스한 터미널 폴더로 업로드 + 경로 삽입 (드롭도 가능)">↥<span class="b-txt"> Attach</span></button>
|
|
564
628
|
<button class="tc-btn" id="focusBtn" title="포커스 모드 — 트리·요청바 숨기고 페인만 (⌘.)">◱<span class="b-txt"> Focus</span></button>
|
|
565
|
-
<button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>×<span class="b-txt">
|
|
629
|
+
<button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>×<span class="b-txt"> Pane</span></button>
|
|
566
630
|
</div>
|
|
567
631
|
</div>
|
|
568
632
|
<div class="panes" id="panes"></div>
|
|
@@ -655,6 +719,21 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
655
719
|
</div>
|
|
656
720
|
</div>
|
|
657
721
|
|
|
722
|
+
<div class="modal" id="projModal">
|
|
723
|
+
<div class="pick" style="width:min(560px,92vw)">
|
|
724
|
+
<div class="pick-h"><span class="t">+ Add project</span><button class="x" id="projClose" title="닫기">×</button></div>
|
|
725
|
+
<div class="pick-path" id="projPath">…</div>
|
|
726
|
+
<div class="sheet-note">폴더를 눌러 들어갑니다 — <b>경로를 칠 일은 없습니다</b>. git 폴더는 <b>Register</b>(목록에 올리기만 — 디스크의 파일은 손대지 않습니다), <b>빈 폴더</b>는 <b>Start here</b>(빈 초기 커밋 하나를 심고 등록합니다). <b>파일이 있는 폴더에는 아무것도 심지 않습니다.</b></div>
|
|
727
|
+
<div class="pick-list" id="projList"></div>
|
|
728
|
+
<div class="sheet-err" id="projErr" hidden></div>
|
|
729
|
+
<div class="pick-f">
|
|
730
|
+
<button class="home" id="projHome" title="홈으로">⌂ home</button>
|
|
731
|
+
<span id="projHint" style="flex:1;font-size:11px;color:var(--faint)">…</span>
|
|
732
|
+
<button class="go" id="projRegHere" hidden>Register this folder</button>
|
|
733
|
+
</div>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
658
737
|
<div class="modal" id="workModal">
|
|
659
738
|
<div class="pick" style="width:min(460px,92vw)">
|
|
660
739
|
<div class="pick-h"><span class="t">새 작업</span><button class="x" id="workClose" title="닫기">×</button></div>
|
|
@@ -756,7 +835,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
756
835
|
|
|
757
836
|
<div class="modal" id="reapModal">
|
|
758
837
|
<div class="pick" style="width:min(560px,92vw)">
|
|
759
|
-
<div class="pick-h"><span class="t">↻
|
|
838
|
+
<div class="pick-h"><span class="t">↻ Orphans</span><button class="x" id="reapClose" title="닫기">×</button></div>
|
|
760
839
|
<div class="sheet-note">run 기록이 사라졌는데 남아 있는 <b>coxpit-r* tmux 세션</b>들입니다 — <b>살아 있는 run 의 세션은 여기 오르지 않습니다</b>. 빈 셸만 미리 체크했고, <b>무언가 돌고 있는 세션은 표시만 하고 절대 미리 고르지 않습니다</b>. 종료되는 것은 터미널뿐, 폴더·파일은 그대로입니다.</div>
|
|
761
840
|
<div class="pick-list" id="reapList"></div>
|
|
762
841
|
<div class="pick-f">
|
|
@@ -768,7 +847,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
768
847
|
|
|
769
848
|
<div class="modal" id="wtModal">
|
|
770
849
|
<div class="pick" style="width:min(600px,92vw)">
|
|
771
|
-
<div class="pick-h"><span class="t">▤
|
|
850
|
+
<div class="pick-h"><span class="t">▤ Reclaim worktrees</span><button class="x" id="wtClose" title="닫기">×</button></div>
|
|
772
851
|
<div class="pick-path" id="wtTotal">…</div>
|
|
773
852
|
<div class="sheet-note">끝난 run 이 남긴 <b>격리 worktree</b>입니다 — 하나에 node_modules 가 통째로 들어 있어 수백 MB씩 쌓입니다. <b>돌고 있는 run 은 여기 오르지 않습니다.</b> 머지됐거나 export·PR 로 빠져나간 것은 미리 체크했고, <b>아직 아무 데도 없는 변경(미머지)은 표시만 하고 절대 미리 고르지 않습니다</b> — 그 worktree 가 <b>유일한 사본</b>이라 지우면 그 변경은 사라집니다. repo 체크아웃과 그 파일은 그대로입니다.</div>
|
|
774
853
|
<div class="pick-list" id="wtList"></div>
|
|
@@ -853,6 +932,18 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
853
932
|
</div>
|
|
854
933
|
</div>
|
|
855
934
|
|
|
935
|
+
<div class="modal" id="cfmModal">
|
|
936
|
+
<div class="pick" style="width:min(460px,92vw)">
|
|
937
|
+
<div class="pick-h"><span class="t" id="cfmTitle">확인</span><button class="x" id="cfmX" title="닫기">×</button></div>
|
|
938
|
+
<div class="sheet-note" id="cfmMsg"></div>
|
|
939
|
+
<div class="sheet-body"><div class="sheet-tradeoff" id="cfmSub"></div></div>
|
|
940
|
+
<div class="pick-f">
|
|
941
|
+
<button type="button" class="home" id="cfmCancel">취소</button>
|
|
942
|
+
<button type="button" class="go" id="cfmOk">확인</button>
|
|
943
|
+
</div>
|
|
944
|
+
</div>
|
|
945
|
+
</div>
|
|
946
|
+
|
|
856
947
|
<div class="modal" id="palette">
|
|
857
948
|
<div class="pal">
|
|
858
949
|
<input id="palInput" placeholder="이동·명령 검색 (⌘K) · 세션·run·프로젝트·명령" autocomplete="off" spellcheck="false" />
|
|
@@ -890,6 +981,37 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
890
981
|
t.classList.add('show'); clearTimeout(toast._h);
|
|
891
982
|
toast._h=setTimeout(function(){ t.classList.remove('show'); }, act?7000:2600);
|
|
892
983
|
}
|
|
984
|
+
// ── 확인 대화상자 (보드의 confirmUI 와 같은 계약: promise 로 true/false) ──
|
|
985
|
+
// 결정은 여기서, 결과는 토스트에서. 네이티브 confirm 은 쓰지 않는다(DESIGN.md) — 뼈대는
|
|
986
|
+
// 이미 있는 .pick 시트 그대로라 새 컴포넌트도, 새 색도 없다.
|
|
987
|
+
var cfmResolve = null;
|
|
988
|
+
function confirmUI(message, opts){
|
|
989
|
+
opts = opts || {};
|
|
990
|
+
$('cfmTitle').textContent = opts.title || '확인';
|
|
991
|
+
$('cfmMsg').textContent = message;
|
|
992
|
+
$('cfmSub').textContent = opts.sub || '';
|
|
993
|
+
$('cfmOk').textContent = opts.okLabel || '확인';
|
|
994
|
+
$('cfmModal').classList.add('on');
|
|
995
|
+
setTimeout(function(){ try{ $('cfmOk').focus(); }catch(e){} }, 30);
|
|
996
|
+
return new Promise(function(resolve){ cfmResolve = resolve; });
|
|
997
|
+
}
|
|
998
|
+
function cfmClose(v){
|
|
999
|
+
$('cfmModal').classList.remove('on');
|
|
1000
|
+
if (cfmResolve){ var r=cfmResolve; cfmResolve=null; r(v); }
|
|
1001
|
+
}
|
|
1002
|
+
$('cfmOk').addEventListener('click', function(){ cfmClose(true); });
|
|
1003
|
+
$('cfmCancel').addEventListener('click', function(){ cfmClose(false); });
|
|
1004
|
+
$('cfmX').addEventListener('click', function(){ cfmClose(false); });
|
|
1005
|
+
$('cfmModal').addEventListener('click', function(e){ if(e.target===$('cfmModal')) cfmClose(false); });
|
|
1006
|
+
document.addEventListener('keydown', function(e){ if(e.key==='Escape' && $('cfmModal').classList.contains('on')){ e.preventDefault(); cfmClose(false); } });
|
|
1007
|
+
// dry 머지 게이트 (v5.28 H3) — 모의 한 번은 말없이 지나가지 않는다.
|
|
1008
|
+
function confirmDryMerge(n){
|
|
1009
|
+
return confirmUI('이 run 은 dry (모의)입니다 — 정말 머지할까요?', {
|
|
1010
|
+
title: 'dry run 머지',
|
|
1011
|
+
sub: (n>1 ? '고른 '+n+'개가 dry 입니다. ' : '') + 'dry run 의 변경은 모의 스트림이 만든 것입니다 — 진짜 작업이 아닙니다.',
|
|
1012
|
+
okLabel: '그래도 머지',
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
893
1015
|
var V_GLYPH = { pass:'✓ verify', fail:'✗ verify', running:'⋯ verify', error:'! verify' };
|
|
894
1016
|
function vbadge(status){ if (!status || !V_GLYPH[status]) return ''; return '<span class="vbadge '+status+'" data-role="vbadge">'+V_GLYPH[status]+'</span>'; }
|
|
895
1017
|
// 라이브 상태 — run 의 최신 이벤트에서 "지금 뭐 하는지"(도구명/사고)를 뽑는다. 실행 중일 때만.
|
|
@@ -918,6 +1040,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
918
1040
|
function repoOfRun(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; return t?t.repoId:null; }
|
|
919
1041
|
function runIsSession(runId){ var r=runById[runId]; var t=r&&taskById[r.taskId]; var rp=t&&repoById[t.repoId]; return !!(rp&&rp.kind==='sessions'); }
|
|
920
1042
|
|
|
1043
|
+
// ── dry(모의) run 표식 (v5.28 H2) ──
|
|
1044
|
+
// 판정은 한 줄뿐이다: **real===false 인 것만** dry 다. undefined(마이그레이션 이전·아직 안 온
|
|
1045
|
+
// 델타)는 모르는 것이지 dry 가 아니다 — 모르는 것을 모의라고 부르면 그게 거짓 경보다.
|
|
1046
|
+
function isDryRun(r){ return !!r && r.real===false; }
|
|
1047
|
+
function dryChip(r){ return isDryRun(r) ? '<span class="dryc" title="dry run — 모의 스트림이 만든 변경입니다(진짜 에이전트가 아닙니다)">dry</span>' : ''; }
|
|
921
1048
|
// ── 에이전트 상태 (v5.28 A4) ──
|
|
922
1049
|
// 서버가 터미널 출력에서 읽어 보내준 거친 상태만 담는다. 여기 없는 run 은 점이 없다 —
|
|
923
1050
|
// 클라이언트는 상태를 짐작하지도, 전이를 지어내지도 않는다(spec A6). 'unknown'(첫 출력 전)도 담지 않는다.
|
|
@@ -942,6 +1069,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
942
1069
|
var row=$('tree').querySelector('.tnode[data-run="'+runId+'"] .st');
|
|
943
1070
|
if (row){ var r=runById[runId]; row.className='st '+((r&&r.status)||'')+(cls?' '+cls:''); }
|
|
944
1071
|
paintPaneStrip(runId); // 페인 헤더 스트립도 같은 표적 칠하기 — waiting 이면 빠른 답, 없으면 시작/이어서(C1·C2)
|
|
1072
|
+
paintActivity(runId); // 열려 있는 활동 페인의 상태 칩·"지금" 줄·타임라인도 그 자리만(E3)
|
|
945
1073
|
updateWaitChip();
|
|
946
1074
|
raiseAttention(runId, prev, state); // 점·칩은 항상 켜져 있고, 소리/알림만 취향을 탄다
|
|
947
1075
|
}
|
|
@@ -1025,16 +1153,18 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1025
1153
|
$('attnSound').checked = attn.sound;
|
|
1026
1154
|
$('attnNotify').checked = attn.notify;
|
|
1027
1155
|
Object.keys(AT_ON).forEach(function(k){ $(AT_ON[k]).classList.toggle('on', attn.on===k); });
|
|
1028
|
-
|
|
1156
|
+
// v5.28 G4 — 팝오버의 주인은 이제 상단바 ⋯ 다. 무언가 켜져 있으면 그 ⋯ 가 액센트를 든다(켜짐은 헤더에서 보여야 한다).
|
|
1157
|
+
$('topMore').classList.toggle('on', attn.sound||attn.notify);
|
|
1029
1158
|
}
|
|
1159
|
+
// 판 자체는 그대로다(체크박스·세그·닫기 배선 전부) — 여는 손잡이만 상단바 ⋯ 메뉴로 옮겼다.
|
|
1160
|
+
// #apWrap 안에 ⋯ 가 그대로 있으므로 앵커(position:absolute)도, 바깥클릭 가드도 예전 그대로 성립한다.
|
|
1030
1161
|
function setAttnPop(open){
|
|
1031
1162
|
$('attnPop').hidden = !open;
|
|
1032
|
-
$('
|
|
1163
|
+
$('topMore').setAttribute('aria-expanded', open?'true':'false');
|
|
1033
1164
|
if (open) $('attnSound').focus(); // 키보드로 열어도 바로 첫 행에 선다
|
|
1034
1165
|
}
|
|
1035
|
-
$('attnBtn').addEventListener('click', function(e){ e.stopPropagation(); setAttnPop($('attnPop').hidden); });
|
|
1036
1166
|
document.addEventListener('click', function(e){ if(!$('attnPop').hidden && !$('apWrap').contains(e.target)) setAttnPop(false); });
|
|
1037
|
-
document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !$('attnPop').hidden){ e.preventDefault(); setAttnPop(false); $('
|
|
1167
|
+
document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !$('attnPop').hidden){ e.preventDefault(); setAttnPop(false); $('topMore').focus(); } });
|
|
1038
1168
|
$('apWrap').addEventListener('focusout', function(){
|
|
1039
1169
|
setTimeout(function(){ if(!$('apWrap').contains(document.activeElement)) setAttnPop(false); }, 0);
|
|
1040
1170
|
});
|
|
@@ -1064,7 +1194,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1064
1194
|
(d.runs||[]).forEach(function(r){ runById[r.id]=r; });
|
|
1065
1195
|
(d.tasks||[]).forEach(function(t){ taskById[t.id]=t; });
|
|
1066
1196
|
(d.repos||[]).forEach(function(r){ repoById[r.id]=r; });
|
|
1067
|
-
|
|
1197
|
+
// 기계 이름은 위 칩(#mach) 한 곳에만 산다 — v5.28 F1 에서 Workspace 헤더의 사본은 걷어냈다
|
|
1198
|
+
if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; }
|
|
1068
1199
|
// 에이전트 상태 씨앗 — 갓 뜬(또는 재연결한) 코크핏이 다음 델타를 기다리지 않게. 맵은 서버가 준 것으로 통째 교체한다
|
|
1069
1200
|
// (터미널이 떨어진 run 은 서버 맵에서 빠지므로 여기서 자연히 사라진다).
|
|
1070
1201
|
agentState={}; var asm=d.agentStates||{};
|
|
@@ -1128,9 +1259,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1128
1259
|
// ── SCRATCH (자유 세션 = 아직 프로젝트가 아닌 것들) ──
|
|
1129
1260
|
// v6.0 S1: 데이터는 그대로(kind='sessions') — 바뀐 건 이름과 어포던스다. 여기서 바로
|
|
1130
1261
|
// 정리(S1b)하고, 여기서 바로 프로젝트로 졸업(S2)시킨다. 어지러워지는 자리에서 치울 수 있어야 한다.
|
|
1262
|
+
// v5.28 G2: 헤더는 이름과 ⋯ 하나다 — 두 액션(+ Session · Tidy…)은 그 뒤에 살고, 하는 일은 그대로다.
|
|
1131
1263
|
html += '<div class="lbl"><span>Scratch</span><span class="lacts">'
|
|
1132
|
-
+ '<
|
|
1133
|
-
+ '
|
|
1264
|
+
+ '<button type="button" class="tmore" data-menu="scratch" aria-haspopup="menu" aria-expanded="false"'
|
|
1265
|
+
+ ' title="Scratch — 새 세션(폴더 지정) · 정리(터미널 없는 세션 삭제, 폴더는 보존)">⋯</button></span></div>';
|
|
1134
1266
|
if (sessRuns.length){
|
|
1135
1267
|
sessRuns.forEach(function(s){
|
|
1136
1268
|
var r=s.run; var open = tabs[r.id] ? ' open' : '';
|
|
@@ -1143,11 +1275,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1143
1275
|
+ '<button class="del" data-delsession="'+r.id+'" title="세션 삭제 — 터미널만 종료, 폴더·파일은 보존">×</button></div>';
|
|
1144
1276
|
});
|
|
1145
1277
|
} else {
|
|
1146
|
-
html += '<div class="tnode empty" style="padding-left:14px">열린 세션 없음 — + Session
|
|
1278
|
+
html += '<div class="tnode empty" style="padding-left:14px">열린 세션 없음 — ⋯ 에서 + Session</div>';
|
|
1147
1279
|
}
|
|
1148
1280
|
html += '<div class="tree-sep"></div>';
|
|
1149
|
-
|
|
1150
|
-
|
|
1281
|
+
// v5.28 G3: 같은 모양 — 이름과 ⋯ 하나. 프로젝트는 이제 보드에 가지 않고 여기서 태어난다.
|
|
1282
|
+
html += '<div class="lbl"><span>Projects</span><span class="lacts">'
|
|
1283
|
+
+ '<button type="button" class="tmore" data-menu="projects" aria-haspopup="menu" aria-expanded="false"'
|
|
1284
|
+
+ ' title="프로젝트 — 폴더를 골라 등록하거나 빈 폴더에서 새로 시작">⋯</button></span></div>';
|
|
1285
|
+
if (!realRepos.length){ html += '<div class="tnode empty">등록된 repo 가 없습니다 — ⋯ 에서 + Add project</div>'; }
|
|
1151
1286
|
// 프로젝트(repo) 하나 = 섹션 하나. 그 안에 작업(task) 들이 있고, 작업 아래에 세션(run) 이 달린다.
|
|
1152
1287
|
realRepos.forEach(function(repo){
|
|
1153
1288
|
var rk = 'repo'+repo.id;
|
|
@@ -1178,10 +1313,17 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1178
1313
|
el.innerHTML = html;
|
|
1179
1314
|
// 열려 있던 ⋯ 메뉴는 트리가 다시 그려져도 살아 있어야 한다(델타마다 닫히면 못 누른다) —
|
|
1180
1315
|
// 같은 repo 의 새 버튼으로 주인을 옮기고, 그 행이 사라졌으면 그때 닫는다.
|
|
1181
|
-
|
|
1316
|
+
// (Workspace 헤더의 ⋯(#wsMore)는 트리 밖 정적 마크업이라 다시 그려지지 않는다 — 건드리지 않는다.)
|
|
1317
|
+
if (rowMenuOwner && rowMenuOwner.hasAttribute('data-more')){
|
|
1182
1318
|
var again = el.querySelector('[data-more="'+rowMenuOwner.getAttribute('data-more')+'"]');
|
|
1183
1319
|
if (again){ again.setAttribute('aria-expanded','true'); rowMenuOwner=again; } else closeRowMenu();
|
|
1184
1320
|
}
|
|
1321
|
+
// v5.28 G — 섹션 헤더의 ⋯(Scratch·Projects)도 트리 안에 산다. 같은 규칙으로 주인을 옮긴다.
|
|
1322
|
+
// (트리 밖 정적 ⋯ — #wsMore·#topMore — 는 document 에 그대로 있으므로 여기 걸리지 않는다.)
|
|
1323
|
+
else if (rowMenuOwner && rowMenuOwner.hasAttribute('data-menu') && !document.contains(rowMenuOwner)){
|
|
1324
|
+
var back = el.querySelector('[data-menu="'+rowMenuOwner.getAttribute('data-menu')+'"]');
|
|
1325
|
+
if (back){ back.setAttribute('aria-expanded','true'); rowMenuOwner=back; } else closeRowMenu();
|
|
1326
|
+
}
|
|
1185
1327
|
}
|
|
1186
1328
|
// 작업(task) 한 줄 + 그 아래 세션(run) 들. 이름은 작업이 갖고, run 은 역할로 읽힌다.
|
|
1187
1329
|
function taskHTML(t, rns){
|
|
@@ -1198,14 +1340,16 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1198
1340
|
var tip = 'r'+r.id+' · '+r.status+(r.inPlace?' · repo 체크아웃에서 직접 작업(격리 없음)':'');
|
|
1199
1341
|
// 점 하나에 두 층 — 에이전트 상태가 살아 있으면 그게 이기고, 없으면 지금까지의 run 상태 그대로.
|
|
1200
1342
|
s += '<div class="tnode run'+open+'" data-run="'+r.id+'" title="'+esc(tip)+'"><span class="st '+esc(r.status)+' '+asClass(agentStateOf(r.id))+'"></span>'
|
|
1343
|
+
+ dryChip(r)
|
|
1201
1344
|
+ '<span class="n">'+esc(runTreeName(r.id))+'</span>'+(act?'<span class="ract">'+esc(act)+'</span>':'')
|
|
1202
1345
|
+ '<span class="meta">r'+r.id+esc(ip)+'</span></div>';
|
|
1203
1346
|
});
|
|
1204
1347
|
return s;
|
|
1205
1348
|
}
|
|
1206
1349
|
$('tree').addEventListener('click', function(e){
|
|
1207
|
-
|
|
1208
|
-
|
|
1350
|
+
// 섹션 헤더의 ⋯ (Scratch·Projects) — 트리는 델타마다 다시 그려지므로 위임으로 받는다.
|
|
1351
|
+
var sm = e.target.closest('[data-menu]');
|
|
1352
|
+
if (sm){ e.stopPropagation(); openSecMenu(sm); return; }
|
|
1209
1353
|
var pm = e.target.closest('[data-promote]');
|
|
1210
1354
|
if (pm){ e.stopPropagation(); openPromote(+pm.dataset.promote); return; }
|
|
1211
1355
|
// 노드 액션은 접기(data-fold)·열기(data-run) 보다 먼저 가로챈다 — 같은 행 안에 있으므로.
|
|
@@ -1233,22 +1377,51 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1233
1377
|
{ act:'tidy', label:'정리…' },
|
|
1234
1378
|
{ act:'unreg', label:'등록 해제' }
|
|
1235
1379
|
];
|
|
1236
|
-
|
|
1380
|
+
// 여는 자리는 하나다(#rowMenu) — repo 행이든 어느 섹션 헤더든 같은 판을 같은 규칙으로 띄운다.
|
|
1381
|
+
function paintRowMenu(btn, html){
|
|
1237
1382
|
var m=$('rowMenu');
|
|
1238
|
-
|
|
1239
|
-
m.innerHTML = ROW_MENU_ACTS.map(function(a){
|
|
1240
|
-
return '<button type="button" role="menuitem" data-act="'+a.act+'" data-repo="'+repoId+'">'+esc(a.label)+'</button>';
|
|
1241
|
-
}).join('');
|
|
1383
|
+
m.innerHTML = html;
|
|
1242
1384
|
m.hidden=false;
|
|
1243
1385
|
// 버튼 아래 왼쪽 정렬, 화면 밖으로 나가면 안쪽으로 당긴다(fixed 좌표라 레일 스크롤과 무관).
|
|
1244
1386
|
var r=btn.getBoundingClientRect();
|
|
1245
1387
|
var w=m.offsetWidth, h=m.offsetHeight;
|
|
1246
1388
|
m.style.left = Math.max(6, Math.min(r.left, window.innerWidth - w - 6)) + 'px';
|
|
1247
1389
|
m.style.top = ((r.bottom + h + 6 > window.innerHeight) ? Math.max(6, r.top - h - 4) : r.bottom + 4) + 'px';
|
|
1390
|
+
// ⋯ 가 넷이 됐으므로(repo 행·Workspace·섹션·상단바) 주인을 넘길 때 옛 주인의 표시를 반드시 내린다 —
|
|
1391
|
+
// 안 그러면 닫힌 ⋯ 가 aria-expanded="true" 인 채 남아 브랜드색으로 떠 있는다.
|
|
1392
|
+
if (rowMenuOwner && rowMenuOwner!==btn) rowMenuOwner.setAttribute('aria-expanded','false');
|
|
1248
1393
|
btn.setAttribute('aria-expanded','true');
|
|
1249
1394
|
rowMenuOwner=btn;
|
|
1250
1395
|
var first=m.querySelector('button'); if(first) first.focus();
|
|
1251
1396
|
}
|
|
1397
|
+
// v5.28 G1 — ⋯ 는 여럿, 판은 하나. 어느 ⋯ 를 눌러도 같은 판이 같은 규칙으로 뜨고,
|
|
1398
|
+
// 다른 것은 **항목 목록뿐**이다. 두 번째 메뉴 시스템은 만들지 않는다.
|
|
1399
|
+
function openMenuAt(btn, acts, repoId){
|
|
1400
|
+
if (rowMenuOwner===btn && !$('rowMenu').hidden){ closeRowMenu(); return; } // 같은 ⋯ 를 또 누르면 토글
|
|
1401
|
+
paintRowMenu(btn, acts.map(function(a){
|
|
1402
|
+
return '<button type="button" role="menuitem" data-act="'+a.act+'"'
|
|
1403
|
+
+ (repoId==null?'':' data-repo="'+repoId+'"')+'>'+esc(a.label)+'</button>';
|
|
1404
|
+
}).join(''));
|
|
1405
|
+
}
|
|
1406
|
+
function openRowMenu(btn, repoId){ openMenuAt(btn, ROW_MENU_ACTS, repoId); }
|
|
1407
|
+
// ── Workspace 헤더 넘침 메뉴 (v5.28 F1) ──
|
|
1408
|
+
// 헤더는 이름과 ⋯ 하나만 이고, 유지보수 둘은 그 뒤에 산다. 하는 일은 하나도 안 바뀐다 —
|
|
1409
|
+
// 같은 핸들러(openWt·openReap)를 그대로 부른다. 기계 이름은 위 칩(#mach)에 한 번만 선다.
|
|
1410
|
+
var WS_MENU_ACTS = [
|
|
1411
|
+
{ act:'wt', label:'▤ Worktrees' },
|
|
1412
|
+
{ act:'reap', label:'↻ Orphans' }
|
|
1413
|
+
];
|
|
1414
|
+
function openWsMenu(btn){ openMenuAt(btn, WS_MENU_ACTS); }
|
|
1415
|
+
$('wsMore').addEventListener('click', function(e){ e.stopPropagation(); openWsMenu(this); });
|
|
1416
|
+
// ── 섹션·상단바 헤더 넘침 메뉴 (v5.28 G2·G3·G4) ──
|
|
1417
|
+
// 같은 모양을 끝까지 민다: 헤더는 이름(또는 상태) 하나와 ⋯ 하나. 항목은 전부 **이미 있는 핸들러**를 부른다.
|
|
1418
|
+
var SEC_MENU_ACTS = {
|
|
1419
|
+
scratch: [ { act:'newsession', label:'+ Session' }, { act:'scrub', label:'Tidy…' } ],
|
|
1420
|
+
projects: [ { act:'addproject', label:'+ Add project…' } ],
|
|
1421
|
+
top: [ { act:'secrets', label:'∗ Secrets' }, { act:'attn', label:'◎ Notifications' } ]
|
|
1422
|
+
};
|
|
1423
|
+
function openSecMenu(btn){ openMenuAt(btn, SEC_MENU_ACTS[btn.getAttribute('data-menu')]||[]); }
|
|
1424
|
+
$('topMore').addEventListener('click', function(e){ e.stopPropagation(); openSecMenu(this); });
|
|
1252
1425
|
function closeRowMenu(){
|
|
1253
1426
|
var m=$('rowMenu'); if(m.hidden) return;
|
|
1254
1427
|
m.hidden=true; m.innerHTML='';
|
|
@@ -1258,19 +1431,31 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1258
1431
|
var b=e.target.closest('button[data-act]'); if(!b) return;
|
|
1259
1432
|
var id=+b.dataset.repo, act=b.dataset.act;
|
|
1260
1433
|
closeRowMenu();
|
|
1261
|
-
if (act==='
|
|
1434
|
+
if (act==='wt') openWt();
|
|
1435
|
+
else if (act==='reap') openReap();
|
|
1436
|
+
else if (act==='newwork') openNewWork(id);
|
|
1262
1437
|
else if (act==='tidy') openTidy(id);
|
|
1263
1438
|
else if (act==='unreg') openUnreg(id);
|
|
1439
|
+
else if (act==='newsession') openSession();
|
|
1440
|
+
else if (act==='scrub') openScrub();
|
|
1441
|
+
else if (act==='addproject') openAddProject();
|
|
1442
|
+
else if (act==='secrets') openSecrets();
|
|
1443
|
+
// 알림은 판을 다시 짓지 않는다 — 있던 팝오버를 그대로 연다. 한 틱 미루는 이유: 이 클릭이
|
|
1444
|
+
// document 까지 올라가 "바깥 클릭"으로 읽히면 방금 연 판이 도로 닫힌다.
|
|
1445
|
+
else if (act==='attn') setTimeout(function(){ setAttnPop(true); }, 0);
|
|
1264
1446
|
});
|
|
1265
1447
|
// 닫기 배선은 주의 팝오버(#attnPop)와 같은 모양이다 — 바깥 클릭 · Escape(포커스 되돌림) · 포커스 이탈.
|
|
1266
|
-
|
|
1448
|
+
// 여는 버튼은 repo 행의 ⋯([data-more])과 헤더들의 ⋯([data-menu] — Workspace·Scratch·Projects·상단바).
|
|
1449
|
+
// 그중 어느 것을 누른 것도 "바깥"이 아니다(누르자마자 닫히면 토글이 성립하지 않는다).
|
|
1450
|
+
var MENU_BTN_SEL = '[data-more],[data-menu]';
|
|
1451
|
+
document.addEventListener('click', function(e){ if(!$('rowMenu').hidden && !$('rowMenu').contains(e.target) && !e.target.closest(MENU_BTN_SEL)) closeRowMenu(); });
|
|
1267
1452
|
document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !$('rowMenu').hidden){ e.preventDefault(); var o=rowMenuOwner; closeRowMenu(); if(o) o.focus(); } });
|
|
1268
1453
|
$('rowMenu').addEventListener('focusout', function(){
|
|
1269
1454
|
// ⋯ 로 포커스가 돌아간 경우는 닫지 않는다 — 안 그러면 두 번째 클릭이 토글이 아니라 재개방이 된다
|
|
1270
1455
|
// (mousedown 이 먼저 포커스를 옮기고, 그 focusout 이 닫아버린 뒤 click 이 다시 연다).
|
|
1271
1456
|
setTimeout(function(){
|
|
1272
1457
|
var a=document.activeElement;
|
|
1273
|
-
if(!$('rowMenu').hidden && !$('rowMenu').contains(a) && !(a && a.closest && a.closest(
|
|
1458
|
+
if(!$('rowMenu').hidden && !$('rowMenu').contains(a) && !(a && a.closest && a.closest(MENU_BTN_SEL))) closeRowMenu();
|
|
1274
1459
|
}, 0);
|
|
1275
1460
|
});
|
|
1276
1461
|
window.addEventListener('resize', closeRowMenu);
|
|
@@ -1317,14 +1502,29 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1317
1502
|
// 탭 라벨 = 역할 하나(작업 이름은 트리가 이미 보여준다).
|
|
1318
1503
|
function tabName(runId){ return runLabel(runId); }
|
|
1319
1504
|
|
|
1505
|
+
// 탭은 runId 하나로 키를 잡는다 — 한 run 이 두 탭이 될 수 없으니, 대신 **모드**를 갖는다(v5.28 E1).
|
|
1506
|
+
// 에이전트 run 은 활동 페인으로 열리고, 날 PTY 는 사람이 Terminal 로 넘길 때 비로소 만들어진다.
|
|
1320
1507
|
function ensureTab(runId){
|
|
1321
1508
|
if (tabs[runId]) return tabs[runId];
|
|
1509
|
+
var t={ runId:runId, name:tabName(runId), mode:defaultTabMode(runId), term:null, fit:null, ws:null, retry:0,
|
|
1510
|
+
closing:false, host:null, ro:null, opened:false, connected:false, search:null, actHost:null };
|
|
1511
|
+
tabs[runId]=t; tabOrder.push(runId);
|
|
1512
|
+
if (t.mode==='terminal') ensureTerm(t);
|
|
1513
|
+
return t; // open·connect 는 attachHosts 에서(Phase 2 순서: open → connect)
|
|
1514
|
+
}
|
|
1515
|
+
// 터미널 실체(호스트 DOM + xterm + 애드온). 활동 페인에서 넘어오면 여기서 처음 생기고, 그 뒤로는 유지된다.
|
|
1516
|
+
function ensureTerm(t){
|
|
1517
|
+
if (t.term) return t;
|
|
1518
|
+
var runId=t.runId;
|
|
1322
1519
|
var host=document.createElement('div'); host.className='term-host';
|
|
1323
1520
|
var term=new window.Terminal({
|
|
1324
1521
|
fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', monospace",
|
|
1325
1522
|
fontSize: isMobile() ? 11 : 12, cursorBlink: true, allowProposedApi: true, scrollback: 4000,
|
|
1326
1523
|
macOptionClickForcesSelection: true, // ⌥+드래그 = 로컬 선택(마우스모드 앱 안에서도) → mouseup 복사가 됨
|
|
1327
1524
|
rightClickSelectsWord: true,
|
|
1525
|
+
// 검은 바탕에 검은 글자(ANSI black=SGR30) 안 보이던 것 방지 — 배경과 대비가 이 비율 미만인
|
|
1526
|
+
// 전경색은 xterm 이 자동으로 끌어올린다. 3 = 안 보이던 것만 구제하고 의도된 어두운 톤은 대체로 보존(2026-09-18).
|
|
1527
|
+
minimumContrastRatio: 3,
|
|
1328
1528
|
theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
|
|
1329
1529
|
});
|
|
1330
1530
|
var fit=new window.FitAddon.FitAddon(); term.loadAddon(fit);
|
|
@@ -1395,9 +1595,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1395
1595
|
return true;
|
|
1396
1596
|
});
|
|
1397
1597
|
// term.open 은 host 가 DOM 에 붙은 뒤(attachHosts) 최초 1회 — detached 에서 open 하면 렌더러가 안 뜬다.
|
|
1398
|
-
|
|
1598
|
+
t.term=term; t.fit=fit; t.host=host; t.search=search; t.opened=false; t.connected=false;
|
|
1399
1599
|
term.onData(function(d){ if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i',d:d})); });
|
|
1400
|
-
tabs[runId]=t; tabOrder.push(runId);
|
|
1401
1600
|
return t; // open·connect 는 attachHosts 에서(Phase 2 순서: open → connect)
|
|
1402
1601
|
}
|
|
1403
1602
|
|
|
@@ -1575,7 +1774,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1575
1774
|
}
|
|
1576
1775
|
|
|
1577
1776
|
function fitTab(t){ if(!t||!t.fit||!t.term) return; try{ t.fit.fit(); if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'r',cols:t.term.cols,rows:t.term.rows})); }catch(e){} }
|
|
1578
|
-
function fitAllVisible(){ eachLeaf(layout,function(l){
|
|
1777
|
+
function fitAllVisible(){ eachLeaf(layout,function(l){ var t=l.tab!=null?tabs[l.tab]:null; if(t && t.kind!=='viewer' && t.term && t.host && t.host.isConnected) fitTab(t); }); }
|
|
1579
1778
|
|
|
1580
1779
|
// ── 탭 바 렌더(터미널 없음 — 언제든 안전) ──
|
|
1581
1780
|
function renderTabs(){
|
|
@@ -1612,6 +1811,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1612
1811
|
? '<span class="st vdoc">▤</span><span class="nm">'+esc(t.name)+'</span>'
|
|
1613
1812
|
+ zoomBtn + '<button class="x" title="이 페인 닫기(뷰어 유지)">×</button>'
|
|
1614
1813
|
: '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
|
|
1814
|
+
// v5.28 E1 — 이 페인이 무엇을 보여줄지. 에이전트 run 에만 뜬다(손 세션·워크벤치는 터미널 그대로).
|
|
1815
|
+
+ (runIsAgent(node.tab)
|
|
1816
|
+
? '<span class="modes acttog" data-role="mtog">'
|
|
1817
|
+
+ '<button type="button" class="mode'+(t.mode==='activity'?' on':'')+'" data-pmode="activity" data-prun="'+node.tab+'" title="이 에이전트가 지금 하고 있는 일 (이벤트 스트림)">Activity</button>'
|
|
1818
|
+
+ '<button type="button" class="mode'+(t.mode!=='activity'?' on':'')+'" data-pmode="terminal" data-prun="'+node.tab+'" title="이 run 의 worktree 셸 (날 터미널)">Terminal</button></span>'
|
|
1819
|
+
: '')
|
|
1615
1820
|
+ '<span data-role="act" class="pane-act">'+esc(latestActivity(node.tab))+'</span>'
|
|
1616
1821
|
// v5.28 C1·C2 — 이 페인의 에이전트 상태가 정하는 스트립. 상태가 바뀌면 이 자리만 다시 칠한다.
|
|
1617
1822
|
+ '<span data-role="qr" class="qr" data-qrun="'+node.tab+'">'+paneStripHTML(node.tab)+'</span>'
|
|
@@ -1637,8 +1842,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1637
1842
|
el.appendChild(a); el.appendChild(g); el.appendChild(b); return el;
|
|
1638
1843
|
}
|
|
1639
1844
|
// host 를 DOM 에 붙이고, 최초 1회 open → connect(Phase 2 순서). fit 은 render 의 rAF 에서.
|
|
1640
|
-
function attachHosts(){ eachLeaf(layout,function(l){ if(l.tab==null||!tabs[l.tab]) return; var t=tabs[l.tab]; var body=$('panes').querySelector('[data-leafbody="'+l.id+'"]'); if(!body) return;
|
|
1641
|
-
if(t.kind==='viewer') return; // 뷰어는 DOM 만 붙이면 끝(터미널 open/connect 없음)
|
|
1845
|
+
function attachHosts(){ eachLeaf(layout,function(l){ if(l.tab==null||!tabs[l.tab]) return; var t=tabs[l.tab]; var body=$('panes').querySelector('[data-leafbody="'+l.id+'"]'); if(!body) return;
|
|
1846
|
+
if(t.kind==='viewer'){ body.appendChild(t.host); return; } // 뷰어는 DOM 만 붙이면 끝(터미널 open/connect 없음)
|
|
1847
|
+
// 활동 페인(v5.28 E) — PTY 를 만들지 않는다. 터미널은 사람이 Terminal 을 누를 때 생긴다.
|
|
1848
|
+
if(t.mode==='activity'){ body.appendChild(actHostOf(t)); renderActivity(t); return; }
|
|
1849
|
+
ensureTerm(t); body.appendChild(t.host);
|
|
1642
1850
|
if(!t.opened){ try{ t.term.open(t.host); t.opened=true; }catch(e){} }
|
|
1643
1851
|
if(!t.connected){ t.connected=true; connectTab(t); }
|
|
1644
1852
|
}); }
|
|
@@ -1809,6 +2017,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1809
2017
|
var chip=head.querySelector('[data-role=chip]'); if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; }
|
|
1810
2018
|
var vslot=head.querySelector('[data-role=vslot]'); if(vslot) vslot.innerHTML=vbadge(r.verifyStatus);
|
|
1811
2019
|
var act=head.querySelector('[data-role=act]'); if(act) act.textContent=latestActivity(l.tab);
|
|
2020
|
+
// 이벤트 델타가 실린 하이드레이트 — 열려 있는 활동 페인은 **새 줄만 이어붙인다**(통째로 다시 그리지 않는다, E3)
|
|
2021
|
+
paintActivity(l.tab);
|
|
1812
2022
|
});
|
|
1813
2023
|
}
|
|
1814
2024
|
|
|
@@ -1885,6 +2095,22 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1885
2095
|
if (injBtn){ e.stopPropagation(); var il=findLeaf(injBtn.getAttribute('data-inject')); if(il&&il.tab!=null){ setLeafFocus(il.id); openInjMenu(injBtn, il.tab); } return; }
|
|
1886
2096
|
var lockBtn=e.target.closest('[data-lock]');
|
|
1887
2097
|
if (lockBtn){ e.stopPropagation(); startSecretSend(lockBtn.getAttribute('data-lock'), lockBtn); return; }
|
|
2098
|
+
// v5.28 E — 활동 페인. 모드 전환(Activity|Terminal) + 페인 안의 읽기/행동 버튼들.
|
|
2099
|
+
var pmBtn=e.target.closest('[data-pmode]');
|
|
2100
|
+
if (pmBtn){ e.stopPropagation(); setTabMode(tabKeyOf(pmBtn.getAttribute('data-prun')), pmBtn.getAttribute('data-pmode')); return; }
|
|
2101
|
+
var atBtn=e.target.closest('[data-actterm]');
|
|
2102
|
+
if (atBtn){ e.stopPropagation(); setTabMode(tabKeyOf(atBtn.getAttribute('data-actterm')), 'terminal'); return; }
|
|
2103
|
+
var asteerBtn=e.target.closest('[data-actsteer]');
|
|
2104
|
+
if (asteerBtn){ e.stopPropagation(); actSteer(tabKeyOf(asteerBtn.getAttribute('data-actsteer'))); return; }
|
|
2105
|
+
var arevBtn=e.target.closest('[data-actreview]');
|
|
2106
|
+
if (arevBtn){ e.stopPropagation(); gotoBoardRun(arevBtn.getAttribute('data-actreview')); return; }
|
|
2107
|
+
var adiffBtn=e.target.closest('[data-actdiff]');
|
|
2108
|
+
if (adiffBtn){ e.stopPropagation(); actToggleDiff(tabKeyOf(adiffBtn.getAttribute('data-actdiff'))); return; }
|
|
2109
|
+
var awsBtn=e.target.closest('[data-actws]');
|
|
2110
|
+
if (awsBtn){ e.stopPropagation(); actToggleWs(tabKeyOf(awsBtn.getAttribute('data-actws'))); return; }
|
|
2111
|
+
var ajBtn=e.target.closest('[data-actjump]');
|
|
2112
|
+
if (ajBtn){ e.stopPropagation(); var jt=tabs[tabKeyOf(ajBtn.getAttribute('data-actjump'))];
|
|
2113
|
+
if(jt && jt.actHost){ var jel=jt.actHost.querySelector('[data-role=acttl]'); if(jel){ jt._tlPinned=true; jel.scrollTop=jel.scrollHeight; } ajBtn.hidden=true; } return; }
|
|
1888
2114
|
var leaf=e.target.closest('[data-leaf]'); if(!leaf) return;
|
|
1889
2115
|
if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
|
|
1890
2116
|
setLeafFocus(leaf.dataset.leaf);
|
|
@@ -2062,7 +2288,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
2062
2288
|
// ── 모바일 터미널 입력바 — 조합 완료 텍스트를 통째로 포커스 탭의 PTY 로(IME 안전) ──
|
|
2063
2289
|
function focusedWs(){ var rid=focusedRunId(); return (rid!=null && tabs[rid]) ? tabs[rid].ws : null; }
|
|
2064
2290
|
function termSendRaw(d){ var ws=focusedWs(); if(ws && ws.readyState===1) ws.send(JSON.stringify({t:'i',d:d})); }
|
|
2065
|
-
function termSendLine(){ var inp=$('termInput'); var v=inp.value;
|
|
2291
|
+
function termSendLine(){ var inp=$('termInput'); var v=inp.value;
|
|
2292
|
+
// 활동 페인에는 아직 PTY 가 없다 — 친 글을 조용히 삼키지 않고 길을 알려 준다(v5.28 E4).
|
|
2293
|
+
var frid=focusedRunId(); var ft=(frid!=null)?tabs[frid]:null;
|
|
2294
|
+
if(ft && ft.kind!=='viewer' && ft.mode==='activity'){ toast('이 페인은 Activity 입니다 — Open terminal 을 누르면 입력할 수 있어요'); return; }
|
|
2295
|
+
if(!v){ termSendRaw('\\r'); return; } termSendRaw(v+'\\r'); inp.value=''; inp.focus(); }
|
|
2066
2296
|
$('termSend').addEventListener('click', termSendLine);
|
|
2067
2297
|
$('termInput').addEventListener('keydown', function(e){ if(e.isComposing) return; if(e.key==='Enter'){ e.preventDefault(); termSendLine(); } });
|
|
2068
2298
|
// 멀티라인 붙여넣기(D-io) — 한 줄 <input> 은 개행을 삼킨다. 개행이 있으면 기본동작을 막고
|
|
@@ -2470,7 +2700,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
2470
2700
|
function reapCount(){ var n=0; Object.keys(reapSel).forEach(function(k){ if(reapSel[k]) n++; }); return n; }
|
|
2471
2701
|
function renderReap(){
|
|
2472
2702
|
$('reapList').innerHTML = reapRows.length ? reapRows.map(reapRowHTML).join('')
|
|
2473
|
-
: '<div class="pick-row" style="cursor:default;color:var(--faint)"
|
|
2703
|
+
: '<div class="pick-row" style="cursor:default;color:var(--faint)">No orphans — every remaining tmux session belongs to a known run</div>';
|
|
2474
2704
|
var n=reapCount(), busy=0;
|
|
2475
2705
|
reapRows.forEach(function(x){ if(!x.idle) busy++; });
|
|
2476
2706
|
$('reapHint').textContent = reapRows.length
|
|
@@ -2502,12 +2732,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
2502
2732
|
body:JSON.stringify({sessions:names})});
|
|
2503
2733
|
var j=await res.json().catch(function(){return{};});
|
|
2504
2734
|
if (!res.ok){ toast('종료 실패: '+(j.detail||j.error||('HTTP '+res.status))); }
|
|
2505
|
-
else { toast('
|
|
2735
|
+
else { toast('Orphans '+(j.count||0)+'개 종료됨'+((j.skipped&&j.skipped.length)?(' · 건너뜀 '+j.skipped.length+'개'):'')); }
|
|
2506
2736
|
}catch(e){ toast('종료 실패: '+e); }
|
|
2507
2737
|
finally{ reaping=false; }
|
|
2508
2738
|
await loadReap();
|
|
2509
2739
|
}
|
|
2510
|
-
|
|
2740
|
+
// 여는 입구는 Workspace 헤더의 ⋯ 메뉴 하나다(v5.28 F1) — 인라인 링크는 사라졌다.
|
|
2511
2741
|
$('reapClose').addEventListener('click', closeReap);
|
|
2512
2742
|
$('reapModal').addEventListener('click', function(e){ if(e.target===this) closeReap(); });
|
|
2513
2743
|
$('reapGo').addEventListener('click', runReap);
|
|
@@ -2577,7 +2807,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
2577
2807
|
await hydrate();
|
|
2578
2808
|
await loadWt();
|
|
2579
2809
|
}
|
|
2580
|
-
|
|
2810
|
+
// 여는 입구는 Workspace 헤더의 ⋯ 메뉴 하나다(v5.28 F1) — 인라인 링크는 사라졌다.
|
|
2581
2811
|
$('wtClose').addEventListener('click', closeWt);
|
|
2582
2812
|
$('wtModal').addEventListener('click', function(e){ if(e.target===this) closeWt(); });
|
|
2583
2813
|
$('wtGo').addEventListener('click', runWt);
|
|
@@ -2745,6 +2975,68 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
2745
2975
|
$('pickName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('pickGo').click(); } });
|
|
2746
2976
|
$('sessionBtn').addEventListener('click', openSession);
|
|
2747
2977
|
|
|
2978
|
+
// ── v5.28 G5 — 코크핏에서 프로젝트 만들기(Projects ⋯ > + Add project…) ──
|
|
2979
|
+
// 보드의 repo 브라우저와 **같은 창구**(GET /api/browse · POST /api/repos · POST /api/repos/new)를
|
|
2980
|
+
// 코크핏의 시트 껍데기로 가져온 것뿐이다. 새 전송도, 새 동작도 없다 — 옮긴 것은 "닿는 자리"다.
|
|
2981
|
+
// 경로는 절대 타이핑하지 않는다: 폴더를 눌러 들어가고, git 폴더에는 Register, 빈 폴더에는 Start here.
|
|
2982
|
+
var projCur='';
|
|
2983
|
+
function projErr(msg){ var el=$('projErr'); if(!msg){ el.hidden=true; el.textContent=''; return; } el.textContent=msg; el.hidden=false; }
|
|
2984
|
+
function openAddProject(){ $('projModal').classList.add('on'); projErr(''); projBrowse(projCur||''); }
|
|
2985
|
+
function closeAddProject(){ $('projModal').classList.remove('on'); }
|
|
2986
|
+
async function projBrowse(p){
|
|
2987
|
+
$('projList').innerHTML = '<div class="pick-row" style="cursor:default;color:var(--faint)">불러오는 중…</div>';
|
|
2988
|
+
try{
|
|
2989
|
+
var res = await fetch('/api/browse'+(p?('?path='+encodeURIComponent(p)):''));
|
|
2990
|
+
if(!res.ok){ $('projList').innerHTML = '<div class="pick-row" style="color:var(--failed)">'+(res.status===401?'인증이 만료됐어요 — 새로고침 후 다시 로그인':'폴더를 읽을 수 없습니다 (HTTP '+res.status+')')+'</div>'; return; }
|
|
2991
|
+
var d = await res.json();
|
|
2992
|
+
projCur = d.path; $('projPath').textContent = d.path;
|
|
2993
|
+
// 지금 서 있는 폴더 자체가 repo 면 그 자리에서 등록할 수 있다(보드의 Register here 와 같은 규칙).
|
|
2994
|
+
$('projRegHere').hidden = !d.isRepo;
|
|
2995
|
+
$('projHint').textContent = d.isRepo ? '이 폴더는 git repo 입니다 — 바로 등록할 수 있습니다'
|
|
2996
|
+
: '폴더를 눌러 들어갑니다 · git 은 Register · 빈 폴더는 Start here';
|
|
2997
|
+
var html = '';
|
|
2998
|
+
if (d.parent && d.parent!==d.path) html += '<div class="pick-row up" data-projgo="'+esc(d.parent)+'"><span class="ic">↑</span><span>..</span></div>';
|
|
2999
|
+
(d.dirs||[]).forEach(function(x){
|
|
3000
|
+
var full = d.path==='/' ? '/'+x.name : d.path+'/'+x.name;
|
|
3001
|
+
html += '<div class="pick-row" data-projgo="'+esc(full)+'"><span class="ic">'+(x.isRepo?'◆':'▸')+'</span>'
|
|
3002
|
+
+ '<span>'+esc(x.name)+'</span>'
|
|
3003
|
+
+ (x.isRepo ? '<span class="rp">git</span><button type="button" class="pbtn" data-projreg="'+esc(full)+'" title="등록 — 목록에 올리기만 합니다(디스크의 파일은 그대로)">Register</button>'
|
|
3004
|
+
: (x.isEmpty ? '<button type="button" class="pbtn" data-projnew="'+esc(full)+'" title="여기서 시작 — 빈 초기 커밋 하나를 심고 등록합니다">Start here</button>' : ''))
|
|
3005
|
+
+ '</div>';
|
|
3006
|
+
});
|
|
3007
|
+
if (!(d.dirs||[]).length) html += '<div class="pick-row" style="cursor:default;color:var(--faint)">하위 폴더 없음</div>';
|
|
3008
|
+
$('projList').innerHTML = html;
|
|
3009
|
+
}catch(e){ $('projList').innerHTML = '<div class="pick-row" style="color:var(--failed)">폴더를 읽을 수 없습니다</div>'; }
|
|
3010
|
+
}
|
|
3011
|
+
var addingProject=false;
|
|
3012
|
+
// 성공하면 트리가 곧 그 프로젝트를 든다 — hydrate 가 renderTree 를 부르므로 따로 그릴 것이 없다.
|
|
3013
|
+
async function projPost(url, full, okMsg){
|
|
3014
|
+
if (addingProject) return;
|
|
3015
|
+
addingProject=true; projErr('');
|
|
3016
|
+
try{
|
|
3017
|
+
var res = await fetch(url,{method:'POST',headers:{'content-type':'application/json'},
|
|
3018
|
+
body:JSON.stringify({machineSlug:machineSlug(), path:full})});
|
|
3019
|
+
var j = await res.json().catch(function(){return{};});
|
|
3020
|
+
if (res.ok){ closeAddProject(); await hydrate(); toast(okMsg+' · '+full); return; }
|
|
3021
|
+
// 커밋이 없는 repo 는 등록이 거절된다 — 대신 어디로 가야 하는지 그 자리에서 말한다(G6: 창구는 그대로).
|
|
3022
|
+
if (j.code==='NO_COMMITS') projErr('커밋이 아직 없는 repo 입니다 — Start here 로 시작하면 빈 초기 커밋을 심고 등록합니다');
|
|
3023
|
+
else projErr((j.detail||j.error||('HTTP '+res.status)));
|
|
3024
|
+
}catch(e){ projErr('실패: '+e); }
|
|
3025
|
+
finally{ addingProject=false; }
|
|
3026
|
+
}
|
|
3027
|
+
function projRegister(full){ projPost('/api/repos', full, '프로젝트 등록'); }
|
|
3028
|
+
function projStartHere(full){ projPost('/api/repos/new', full, '새 프로젝트 시작'); }
|
|
3029
|
+
$('projList').addEventListener('click', function(e){
|
|
3030
|
+
var rg=e.target.closest('[data-projreg]'); if(rg){ e.stopPropagation(); projRegister(rg.dataset.projreg); return; }
|
|
3031
|
+
var nw=e.target.closest('[data-projnew]'); if(nw){ e.stopPropagation(); projStartHere(nw.dataset.projnew); return; }
|
|
3032
|
+
var go=e.target.closest('[data-projgo]'); if(go){ projErr(''); projBrowse(go.dataset.projgo); }
|
|
3033
|
+
});
|
|
3034
|
+
$('projRegHere').addEventListener('click', function(){ if(projCur) projRegister(projCur); });
|
|
3035
|
+
$('projHome').addEventListener('click', function(){ projErr(''); projBrowse(''); });
|
|
3036
|
+
$('projClose').addEventListener('click', closeAddProject);
|
|
3037
|
+
$('projModal').addEventListener('click', function(e){ if(e.target===this) closeAddProject(); });
|
|
3038
|
+
document.addEventListener('keydown', function(e){ if(e.key==='Escape' && $('projModal').classList.contains('on')){ e.preventDefault(); closeAddProject(); } });
|
|
3039
|
+
|
|
2748
3040
|
// ── v6.0 Part T — 새 작업(+ 새 작업) / 에이전트 추가(+ 에이전트) ──
|
|
2749
3041
|
// 작업 = task(이름이 사는 곳), 그 아래 세션 = run. 작업을 만들면 repo 체크아웃에
|
|
2750
3042
|
// main 터미널(root 세션)이 바로 열리고, 에이전트는 그 작업 아래에 worktree 로 붙는다.
|
|
@@ -2931,7 +3223,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
2931
3223
|
else{ var j=await res.json().catch(function(){return{};}); toast('저장 실패: '+(j.error||res.status)); }
|
|
2932
3224
|
}catch(e){ toast('저장 실패: '+e); }
|
|
2933
3225
|
}
|
|
2934
|
-
|
|
3226
|
+
// 여는 자리는 둘 — 상단바 ⋯ 메뉴의 ∗ Secrets(v5.28 G4)와 ⌘K 팔레트. 헤더의 인라인 버튼은 그 메뉴로 들어갔다.
|
|
2935
3227
|
$('secretsClose').addEventListener('click', closeSecrets);
|
|
2936
3228
|
$('secretsModal').addEventListener('click', function(e){ if(e.target===this) closeSecrets(); });
|
|
2937
3229
|
$('secAdd').addEventListener('click', addSecret);
|
|
@@ -3215,11 +3507,184 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
3215
3507
|
document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !$('injMenu').hidden){ e.preventDefault(); var o=injMenuOwner; closeInjMenu(); if(o) o.focus(); } });
|
|
3216
3508
|
window.addEventListener('resize', closeInjMenu);
|
|
3217
3509
|
|
|
3510
|
+
// ══ v5.28 Part E — 활동 페인(run activity view) ═════════════════════════════
|
|
3511
|
+
// 돌고 있는 에이전트를 눌렀을 때 보여야 하는 것은 **그 에이전트가 하고 있는 일**이다.
|
|
3512
|
+
// 헤드리스 run 의 tmux 는 빈 worktree 셸이고(에이전트는 떨어져 나가 돈다), 진짜 작업은
|
|
3513
|
+
// 이미 파싱돼 있는 이벤트 스트림(r.events · latestActivity · Part A agentstate)에 있다.
|
|
3514
|
+
// 이 페인은 그것을 **읽을 뿐**이다 — 새 창구도, 새 색도, 새 상태도 만들지 않는다.
|
|
3515
|
+
// ① 터미널은 없어지지 않는다(액션바 첫 버튼이 한 번에 되돌린다)
|
|
3516
|
+
// ② 없는 것은 지어내지 않는다(아직 이벤트가 없으면 starting…)
|
|
3517
|
+
// ③ 에이전트 run 에만 뜬다(손 세션·워크벤치는 진짜 셸이라 터미널이 옳다)
|
|
3518
|
+
|
|
3519
|
+
// 보드 타임라인과 **같은 한 벌**(src/humanize.ts) — 같은 줄을 같은 규칙으로. 사본을 만들지 않는다.
|
|
3520
|
+
${HUMANIZE_JS}
|
|
3521
|
+
|
|
3522
|
+
// 에이전트 run = 이벤트 스트림을 가진 run. 자유 세션·손 터미널(agent='session')·워크벤치는 아니다.
|
|
3523
|
+
function runIsAgent(runId){
|
|
3524
|
+
if(runId==null || isViewer(runId)) return false;
|
|
3525
|
+
var r=runById[runId]; if(!r) return false;
|
|
3526
|
+
if(runIsSession(runId)) return false;
|
|
3527
|
+
return r.agent!=='session' && r.agent!=='workbench';
|
|
3528
|
+
}
|
|
3529
|
+
// 탭이 열릴 때의 기본 모드(E1). 모르는 run 은 오늘과 똑같이 터미널이다.
|
|
3530
|
+
function defaultTabMode(runId){ return runIsAgent(runId) ? 'activity' : 'terminal'; }
|
|
3531
|
+
function actHostOf(t){
|
|
3532
|
+
if(!t.actHost){ var h=document.createElement('div'); h.className='act-host'; t.actHost=h; }
|
|
3533
|
+
return t.actHost;
|
|
3534
|
+
}
|
|
3535
|
+
// 모드 전환 — 터미널은 **여기서 처음** 만들어지고(lazy), 한 번 만든 뒤로는 계속 산다(되돌아오기는 즉시).
|
|
3536
|
+
function setTabMode(runId, mode){
|
|
3537
|
+
var t=tabs[runId]; if(!t || t.kind==='viewer') return;
|
|
3538
|
+
var want=(mode==='terminal')?'terminal':'activity';
|
|
3539
|
+
if(t.mode===want) return;
|
|
3540
|
+
t.mode=want;
|
|
3541
|
+
if(want==='terminal') ensureTerm(t);
|
|
3542
|
+
render();
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
// "지금" 한 줄 — 파싱된 스트림에서 온 것만. 아직 아무 이벤트도 없으면 지어내지 않고 starting… 이라 말한다.
|
|
3546
|
+
function actNowText(runId){
|
|
3547
|
+
var r=runById[runId]; if(!r) return '';
|
|
3548
|
+
var live=(r.status==='running'||r.status==='pending'||r.status==='preparing');
|
|
3549
|
+
if(!(r.events||[]).length) return live ? 'starting…' : String(r.status||'');
|
|
3550
|
+
return latestActivity(runId) || String(r.status||'');
|
|
3551
|
+
}
|
|
3552
|
+
function actPath(runId){
|
|
3553
|
+
var r=runById[runId]; if(!r) return '';
|
|
3554
|
+
var task=taskById[r.taskId]; var rp=task&&repoById[task.repoId];
|
|
3555
|
+
return (rp?rp.name:'?')+' ▸ '+((task&&task.title)||('task '+(r.taskId!=null?r.taskId:'?')));
|
|
3556
|
+
}
|
|
3557
|
+
// 모델 · 브랜치. 브랜치가 없는 run 은 없다고 말한다(in-place — 트리 행·Review 와 같은 표기).
|
|
3558
|
+
function actMeta(runId){
|
|
3559
|
+
var r=runById[runId]; if(!r) return '';
|
|
3560
|
+
var bits=[]; if(r.model) bits.push(r.model);
|
|
3561
|
+
bits.push(r.branch || 'in-place');
|
|
3562
|
+
return bits.join(' · ');
|
|
3563
|
+
}
|
|
3564
|
+
function actFilesText(runId){ var r=runById[runId]; return 'filesChanged '+((r&&r.filesChanged)||0); }
|
|
3565
|
+
|
|
3566
|
+
function activityHTML(runId){
|
|
3567
|
+
var s=agentStateOf(runId);
|
|
3568
|
+
return '<div class="act-h">'
|
|
3569
|
+
+ '<span class="anm">'+esc(runLabel(runId))+'</span>'
|
|
3570
|
+
+ '<span class="apath">'+esc(actPath(runId))+'</span>'
|
|
3571
|
+
+ '<span class="act-chip '+asClass(s)+'" data-role="actchip">'+esc(s)+'</span>'
|
|
3572
|
+
+ dryChip(runById[runId])
|
|
3573
|
+
+ '<span class="ameta">'+esc(actMeta(runId))+'</span>'
|
|
3574
|
+
+ '</div>'
|
|
3575
|
+
+ '<div class="act-now" data-role="actnow">'+esc(actNowText(runId))+'</div>'
|
|
3576
|
+
+ '<div class="act-tlwrap"><div class="act-tl" data-role="acttl"></div>'
|
|
3577
|
+
+ '<button type="button" class="act-jump" data-role="actjump" data-actjump="'+runId+'" hidden>Latest ↓</button></div>'
|
|
3578
|
+
+ '<div class="act-chg"><div class="act-chg-h">'
|
|
3579
|
+
+ '<span class="afiles" data-role="actfiles">'+esc(actFilesText(runId))+'</span>'
|
|
3580
|
+
+ '<button type="button" class="abtn" data-actdiff="'+runId+'" title="Diff so far — same renderer as Review">Changes</button>'
|
|
3581
|
+
+ '<button type="button" class="rv-ws" data-actws="'+runId+'" title="Show whitespace (space/tab)" hidden>␣ WS</button>'
|
|
3582
|
+
+ '</div><div class="rv-diff" data-role="actdiffbody" hidden></div></div>'
|
|
3583
|
+
+ '<div class="act-bar">'
|
|
3584
|
+
+ '<button type="button" class="abtn prim" data-actterm="'+runId+'" title="이 페인을 날 터미널(worktree 셸)로 — 언제든 한 번에 돌아옵니다">Open terminal</button>'
|
|
3585
|
+
+ '<button type="button" class="abtn" data-actsteer="'+runId+'" title="이 run 에 후속 지시 (요청바 Steer 경로 그대로)">Steer…</button>'
|
|
3586
|
+
// 대기 중일 때만 Part C 빠른 답이 그대로 선다 — 여기서 새로 만들지 않는다.
|
|
3587
|
+
// 자리 이름은 data-arun 이다(헤더 스트립의 data-qrun 과 달라서, paintPaneStrip 이 이 칸을 덧칠하지 않는다).
|
|
3588
|
+
+ '<span class="qr" data-role="qr" data-arun="'+runId+'">'+(s==='waiting'?paneStripHTML(runId):'')+'</span>'
|
|
3589
|
+
+ '<button type="button" class="abtn" data-actreview="'+runId+'" title="보드의 run 상세로 (전체 타임라인·산출물)">Review</button>'
|
|
3590
|
+
+ '</div>';
|
|
3591
|
+
}
|
|
3592
|
+
function renderActivity(t){
|
|
3593
|
+
var h=actHostOf(t);
|
|
3594
|
+
h.innerHTML=activityHTML(t.runId);
|
|
3595
|
+
t._tlKeys=null; t._tlPinned=true;
|
|
3596
|
+
var tl=h.querySelector('[data-role=acttl]');
|
|
3597
|
+
if(tl) tl.addEventListener('scroll', function(){
|
|
3598
|
+
var atEnd=(tl.scrollHeight - tl.scrollTop - tl.clientHeight) < 24;
|
|
3599
|
+
t._tlPinned=atEnd;
|
|
3600
|
+
var j=h.querySelector('[data-role=actjump]'); if(j) j.hidden=atEnd;
|
|
3601
|
+
});
|
|
3602
|
+
actPaintTimeline(t);
|
|
3603
|
+
if(t._diffOpen){ var db=h.querySelector('[data-actdiff]'); if(db) db.textContent='Hide changes'; actLoadDiff(t.runId); }
|
|
3604
|
+
}
|
|
3605
|
+
function actKey(h){ return h.k+'\\u0000'+h.t; }
|
|
3606
|
+
// 새 줄은 **아래에 잇는다**. 통째로 다시 그리는 것은 이을 자리를 못 찾았을 때뿐이다
|
|
3607
|
+
// (이벤트 창이 40개로 밀려 앞이 잘린 경우). 꼬리에 붙어 있을 때만 따라 내려간다.
|
|
3608
|
+
function actPaintTimeline(t){
|
|
3609
|
+
var h=t.actHost; if(!h) return;
|
|
3610
|
+
var el=h.querySelector('[data-role=acttl]'); if(!el) return;
|
|
3611
|
+
var r=runById[t.runId];
|
|
3612
|
+
var lines=humanLines((r&&r.events)||[]);
|
|
3613
|
+
var prev=t._tlKeys, start=0;
|
|
3614
|
+
if(!prev || !prev.length){ el.innerHTML=''; prev=[]; }
|
|
3615
|
+
else {
|
|
3616
|
+
var last=prev[prev.length-1], at=-1;
|
|
3617
|
+
for(var i=lines.length-1;i>=0;i--){ if(actKey(lines[i])===last){ at=i; break; } }
|
|
3618
|
+
if(at<0){ el.innerHTML=''; prev=[]; } else start=at+1;
|
|
3619
|
+
}
|
|
3620
|
+
for(var j=start;j<lines.length;j++){
|
|
3621
|
+
var d=document.createElement('div'); d.className='act-l';
|
|
3622
|
+
d.innerHTML='<span class="ak">'+esc(lines[j].k)+'</span><span class="at">'+esc(lines[j].t)+'</span>';
|
|
3623
|
+
el.appendChild(d); prev.push(actKey(lines[j]));
|
|
3624
|
+
}
|
|
3625
|
+
t._tlKeys=prev;
|
|
3626
|
+
var jb=h.querySelector('[data-role=actjump]');
|
|
3627
|
+
if(!prev.length){ el.innerHTML='<div class="act-empty">No events yet — starting…</div>'; if(jb) jb.hidden=true; return; }
|
|
3628
|
+
if(t._tlPinned){ el.scrollTop=el.scrollHeight; if(jb) jb.hidden=true; }
|
|
3629
|
+
else if(start<lines.length && jb) jb.hidden=false;
|
|
3630
|
+
}
|
|
3631
|
+
// 델타 한 건 = 열려 있는 활동 페인의 그 자리만 고쳐 칠한다(리하이드레이트 없음 — A4 규율 그대로).
|
|
3632
|
+
function paintActivity(runId){
|
|
3633
|
+
var t=tabs[runId]; if(!t || t.kind==='viewer' || t.mode!=='activity' || !t.actHost) return;
|
|
3634
|
+
var h=t.actHost, s=agentStateOf(runId);
|
|
3635
|
+
var chip=h.querySelector('[data-role=actchip]'); if(chip){ chip.className='act-chip '+asClass(s); chip.textContent=s; }
|
|
3636
|
+
var now=h.querySelector('[data-role=actnow]'); if(now) now.textContent=actNowText(runId);
|
|
3637
|
+
var files=h.querySelector('[data-role=actfiles]'); if(files) files.textContent=actFilesText(runId);
|
|
3638
|
+
var qr=h.querySelector('[data-role=qr]'); if(qr) qr.innerHTML=(s==='waiting')?paneStripHTML(runId):'';
|
|
3639
|
+
actPaintTimeline(t);
|
|
3640
|
+
}
|
|
3641
|
+
// 변경 미리보기 — Review 가 쓰는 그 창구(/api/runs/:id/diff)와 그 렌더러(diffHTML·공백 토글) 그대로.
|
|
3642
|
+
function actLoadDiff(runId){
|
|
3643
|
+
var t=tabs[runId]; if(!t||!t.actHost) return;
|
|
3644
|
+
var body=t.actHost.querySelector('[data-role=actdiffbody]'); if(!body) return;
|
|
3645
|
+
body.hidden=false; body.textContent='Loading…';
|
|
3646
|
+
var wsBtn=t.actHost.querySelector('[data-actws]'); if(wsBtn){ wsBtn.hidden=false; wsBtn.classList.toggle('on', rvShowWs); }
|
|
3647
|
+
fetch('/api/runs/'+runId+'/diff').then(function(x){ return x.json(); }).then(function(d){
|
|
3648
|
+
if(tabs[runId]!==t || !t._diffOpen) return;
|
|
3649
|
+
if(d && d.ok) body.innerHTML=diffHTML(d.diff||'');
|
|
3650
|
+
else body.textContent=(d&&d.stat)||'worktree 가 없어 diff 를 볼 수 없습니다';
|
|
3651
|
+
}).catch(function(){ body.textContent='diff 를 가져오지 못했습니다'; });
|
|
3652
|
+
}
|
|
3653
|
+
function actToggleDiff(runId){
|
|
3654
|
+
var t=tabs[runId]; if(!t||!t.actHost) return;
|
|
3655
|
+
t._diffOpen=!t._diffOpen;
|
|
3656
|
+
var btn=t.actHost.querySelector('[data-actdiff]'); if(btn) btn.textContent=t._diffOpen?'Hide changes':'Changes';
|
|
3657
|
+
if(t._diffOpen){ actLoadDiff(runId); return; }
|
|
3658
|
+
var body=t.actHost.querySelector('[data-role=actdiffbody]'); if(body){ body.hidden=true; body.innerHTML=''; }
|
|
3659
|
+
var wsBtn=t.actHost.querySelector('[data-actws]'); if(wsBtn) wsBtn.hidden=true;
|
|
3660
|
+
}
|
|
3661
|
+
// 공백 표시는 Review 와 **같은 취향 하나**를 나눠 쓴다(두 군데서 따로 기억하지 않는다).
|
|
3662
|
+
function actToggleWs(runId){
|
|
3663
|
+
rvShowWs=!rvShowWs;
|
|
3664
|
+
try{ $('rvWs').classList.toggle('on', rvShowWs); }catch(e){}
|
|
3665
|
+
var t=tabs[runId];
|
|
3666
|
+
if(t && t.actHost){ var b=t.actHost.querySelector('[data-actws]'); if(b) b.classList.toggle('on', rvShowWs); }
|
|
3667
|
+
if(t && t._diffOpen) actLoadDiff(runId);
|
|
3668
|
+
if(reviewOn && rvTaskId!=null) loadCompare(rvTaskId);
|
|
3669
|
+
}
|
|
3670
|
+
// Steer… = 요청바의 steer 경로 그대로(새 창구 없음). 빈 지시는 보내지 않고 작성칸에 세워 준다.
|
|
3671
|
+
function actSteer(runId){
|
|
3672
|
+
var l=null; eachLeaf(layout,function(x){ if(l==null && x.tab===runId) l=x; });
|
|
3673
|
+
if(l) setLeafFocus(l.id);
|
|
3674
|
+
setMode('steer');
|
|
3675
|
+
try{ $('reqInput').focus(); }catch(e){}
|
|
3676
|
+
toast('r'+runId+' 에 이어서 보낼 지시를 적고 ⏎');
|
|
3677
|
+
}
|
|
3678
|
+
// Review = 보드의 run 상세. 이미 있는 /?run=N 딥링크로 갈 뿐, 코크핏이 상세를 다시 그리지 않는다.
|
|
3679
|
+
function gotoBoardRun(runId){ location.href='/?run='+encodeURIComponent(runId); }
|
|
3680
|
+
|
|
3218
3681
|
// ── 요청바: New(팬아웃) / Steer / Broadcast ──
|
|
3219
3682
|
var reqMode = 'new';
|
|
3220
3683
|
function setMode(m){
|
|
3221
3684
|
reqMode = m;
|
|
3222
|
-
|
|
3685
|
+
// 요청바의 세그만 — 같은 .mode 알갱이를 쓰는 다른 세그(시트의 provider·place, 페인의 Activity|Terminal)는
|
|
3686
|
+
// 자기 'on' 을 스스로 들고 있다. data-mode 를 가진 것만 만진다.
|
|
3687
|
+
Array.prototype.forEach.call(document.querySelectorAll('.mode[data-mode]'), function(b){ b.classList.toggle('on', b.dataset.mode===m); });
|
|
3223
3688
|
var go=$('reqGo'), inp=$('reqInput');
|
|
3224
3689
|
$('newCtl').style.display = m==='new'?'inline-flex':'none';
|
|
3225
3690
|
$('reqTgt').style.display = m==='new'?'none':'inline';
|
|
@@ -3354,12 +3819,26 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
3354
3819
|
+ '<span class="vout" data-vout="'+r.id+'" title="클릭=전체 출력">'+esc(txt+(out?' — '+out:''))+'</span>'
|
|
3355
3820
|
+ '<button class="rv-reverify" data-reverify="'+r.id+'">re-verify</button></div>';
|
|
3356
3821
|
}
|
|
3822
|
+
// 머지된 승자의 base 검증(J2) — 내려앉은 그 호흡의 판정을 머지를 누른 그 자리에 남긴다.
|
|
3823
|
+
// 서버가 머지 응답에 실어 보낸 것만 그린다. 실패는 보고일 뿐, 머지는 그대로 서 있다.
|
|
3824
|
+
var mv = mergeVerifyById[r.id]; var mvline = '';
|
|
3825
|
+
if (mv && mv.status){
|
|
3826
|
+
mvline = '<div class="rv-verify" data-role="mverify">'+(vbadge(mv.status)||'<span class="vbadge error">· verify</span>')
|
|
3827
|
+
+ '<span class="vout" data-mvout="'+r.id+'" title="클릭=전체 출력">'
|
|
3828
|
+
+ esc('merged · verify: '+mv.status+(mv.status==='pass'?'':' — 머지는 됐지만 검증 실패 — 로그 확인'))+'</span></div>';
|
|
3829
|
+
} else if (mv){
|
|
3830
|
+
mvline = '<div class="rv-verify" data-role="mverify"><span class="vout" data-vnudge="1" title="위 verify 칸으로">'
|
|
3831
|
+
+ esc('merged · 검증 명령이 없어요 — repo 설정에서 verifyCmd 를 지정하면 승자를 자동 검증합니다')+'</span></div>';
|
|
3832
|
+
}
|
|
3357
3833
|
col.innerHTML = '<div class="rv-col-h"><span class="st '+esc(r.status)+'" style="width:7px;height:7px;border-radius:50%;display:inline-block"></span>'
|
|
3358
3834
|
+ '<span class="rid">r'+r.id+'</span><span class="chip '+esc(r.status)+'">'+esc(r.status)+'</span>'
|
|
3835
|
+
// dry 후보는 열에서부터 표가 난다 — 나란히 놓고 고르는 자리가 실수가 나는 자리였다(H3).
|
|
3836
|
+
+ dryChip(r)
|
|
3359
3837
|
+ '<span class="stat">'+esc(st)+'</span>'
|
|
3360
|
-
+ '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+((r.status==='merged'||!mergeable)?' disabled':'')
|
|
3838
|
+
+ '<button class="rv-merge'+(gated?' caution':'')+'" data-merge="'+r.id+'"'+(isDryRun(r)?' data-dry="1"':'')+((r.status==='merged'||!mergeable)?' disabled':'')
|
|
3361
3839
|
+ (noIso?' title="체크아웃에서 직접 작업 — 머지할 것이 없습니다(아래는 커밋 안 한 변경)"':(gated?' title="검증 미통과 — 그래도 머지"':''))+'>'+mlabel+'</button></div>'
|
|
3362
3840
|
+ vline
|
|
3841
|
+
+ mvline
|
|
3363
3842
|
+ '<div class="rv-diff">'+diffHTML(r.diff)+'</div>';
|
|
3364
3843
|
col._vout = { text: r.verifyOutput||'' };
|
|
3365
3844
|
host.appendChild(col);
|
|
@@ -3369,7 +3848,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
3369
3848
|
}catch(e){ host.innerHTML='<div class="rv-empty">compare 실패: '+esc(String(e))+'</div>'; }
|
|
3370
3849
|
}
|
|
3371
3850
|
var voutById = {};
|
|
3851
|
+
/* 머지 응답이 실어 온 base 검증 결과(v5.28 J2). 다시 그려도 그 열에 남아 있게 여기 둔다. */
|
|
3852
|
+
var mergeVerifyById = {};
|
|
3372
3853
|
$('rvCols').addEventListener('click', async function(e){
|
|
3854
|
+
var mo = e.target.closest('[data-mvout]');
|
|
3855
|
+
if (mo){ var mvv = mergeVerifyById[mo.dataset.mvout]; var mt = (mvv&&mvv.output)||'';
|
|
3856
|
+
toast(mt? mt.slice(-600) : '검증 출력 없음'); return; }
|
|
3857
|
+
var nd = e.target.closest('[data-vnudge]');
|
|
3858
|
+
if (nd){ $('rvVcmd').focus(); $('rvVcmd').select();
|
|
3859
|
+
toast('verify 명령을 적고 save — 다음 머지부터 승자를 자동 검증합니다'); return; }
|
|
3373
3860
|
var vo = e.target.closest('[data-vout]');
|
|
3374
3861
|
if (vo){ var t = voutById[vo.dataset.vout]||''; toast(t? t.slice(-600) : '검증 출력 없음'); return; }
|
|
3375
3862
|
var rv = e.target.closest('[data-reverify]');
|
|
@@ -3378,11 +3865,20 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
3378
3865
|
catch(err){ toast('재검증 실패: '+err); rv.disabled=false; rv.textContent='re-verify'; }
|
|
3379
3866
|
return; }
|
|
3380
3867
|
var b = e.target.closest('[data-merge]'); if (!b) return;
|
|
3381
|
-
var rid = b.dataset.merge;
|
|
3868
|
+
var rid = b.dataset.merge;
|
|
3869
|
+
// 모의가 진짜인 척 base 로 넘어가지 않게 — 아는 dry 에만 한 번 묻는다(H3).
|
|
3870
|
+
if (b.getAttribute('data-dry')==='1' && !(await confirmDryMerge(1))) return;
|
|
3871
|
+
var prev=b.textContent; b.disabled=true; b.textContent='merging…';
|
|
3382
3872
|
try{
|
|
3383
3873
|
var res = await fetch('/api/runs/'+rid+'/merge',{method:'POST'});
|
|
3384
3874
|
var j = await res.json().catch(function(){return{};});
|
|
3385
|
-
if (res.ok){
|
|
3875
|
+
if (res.ok){
|
|
3876
|
+
// 검증은 머지 다음의 한 호흡이지 머지의 조건이 아니다 — 실패해도 되돌리지 않고 말만 한다(J1/J3).
|
|
3877
|
+
mergeVerifyById[rid] = (j && j.verify) || { status:'', output:'' };
|
|
3878
|
+
var mvs = mergeVerifyById[rid].status;
|
|
3879
|
+
toast('r'+rid+' merge 완료'+(mvs ? ' · verify: '+mvs : ' · 검증 명령 없음'));
|
|
3880
|
+
await hydrate(); if (rvTaskId!=null) loadCompare(rvTaskId);
|
|
3881
|
+
}
|
|
3386
3882
|
else { toast('merge 불가: '+(j.error||j.reason||res.status)); b.disabled=false; b.textContent=prev; }
|
|
3387
3883
|
}catch(err){ toast('merge 실패: '+err); b.disabled=false; b.textContent=prev; }
|
|
3388
3884
|
});
|