coxpit 5.10.0 → 5.12.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/LICENSE.md +717 -0
- package/README.md +5 -1
- package/package.json +2 -2
- package/src/cockpit.ts +429 -163
- package/src/db/index.ts +1 -0
- package/src/db/schema.ts +1 -0
- package/src/orchestrator.ts +53 -1
- package/src/server.ts +26 -1
- package/LICENSE +0 -21
package/src/cockpit.ts
CHANGED
|
@@ -74,36 +74,57 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
74
74
|
.tree-sep{height:1px;background:var(--line);margin:8px 4px}
|
|
75
75
|
.railfoot{margin-top:auto;padding:8px;color:var(--faint);font-size:11px;border-top:1px solid var(--line)}
|
|
76
76
|
|
|
77
|
-
/* ──
|
|
78
|
-
.stage{display:flex;flex-direction:column;min-width:0;background:var(--bg)}
|
|
79
|
-
.
|
|
80
|
-
.
|
|
81
|
-
.
|
|
82
|
-
.
|
|
83
|
-
.
|
|
84
|
-
.
|
|
85
|
-
.
|
|
86
|
-
.
|
|
87
|
-
.
|
|
88
|
-
.
|
|
89
|
-
.
|
|
90
|
-
.
|
|
77
|
+
/* ── 탭 바 + 분할 트리 페인 ── */
|
|
78
|
+
.stage{display:flex;flex-direction:column;min-width:0;background:var(--bg);position:relative}
|
|
79
|
+
.tabbar{display:flex;align-items:stretch;height:38px;border-bottom:1px solid var(--line);background:var(--panel);font-family:var(--mono)}
|
|
80
|
+
.tabs{flex:1;display:flex;align-items:stretch;overflow-x:auto;overflow-y:hidden}
|
|
81
|
+
.tab{display:inline-flex;align-items:center;gap:7px;padding:0 10px 0 12px;border-right:1px solid var(--line);font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap;max-width:220px;user-select:none;flex:none}
|
|
82
|
+
.tab:hover{background:var(--surface)}
|
|
83
|
+
.tab.shown{color:var(--ink);background:var(--bg);box-shadow:inset 0 -2px 0 var(--brand)}
|
|
84
|
+
.tab.drag{opacity:.4}
|
|
85
|
+
.tab .st{flex:none}
|
|
86
|
+
.tab .nm{overflow:hidden;text-overflow:ellipsis}
|
|
87
|
+
.tab .ren{font:inherit;color:var(--ink);background:var(--panel);border:1px solid var(--brand);border-radius:4px;padding:0 4px;width:120px;outline:none}
|
|
88
|
+
.tab .x{color:var(--faint);font-size:13px;padding:0 2px;border:none;background:none;cursor:pointer;line-height:1}
|
|
89
|
+
.tab .x:hover{color:var(--failed)}
|
|
90
|
+
.tabctl{display:flex;align-items:center;gap:6px;padding:0 10px;border-left:1px solid var(--line)}
|
|
91
|
+
.tc-btn{background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:11px;padding:3px 8px;cursor:pointer;font-family:var(--mono)}
|
|
92
|
+
.tc-btn:hover:not([disabled]){color:var(--ink);border-color:var(--line-hi)}
|
|
93
|
+
.tc-btn[disabled]{opacity:.4;cursor:default}
|
|
94
|
+
.tc-btn.session{color:var(--brand);border-color:rgba(78,201,176,.35)}
|
|
95
|
+
.tc-btn.session:hover:not([disabled]){background:var(--brand-dim);border-color:var(--brand)}
|
|
96
|
+
|
|
97
|
+
.panes{flex:1;display:none;min-height:0;min-width:0} /* 초기 숨김 — 탭 있을 때만 flex */
|
|
98
|
+
.node{display:flex;min-width:0;min-height:0;flex:1 1 0}
|
|
99
|
+
.node.row{flex-direction:row} .node.col{flex-direction:column}
|
|
100
|
+
.gutter{flex:none;background:var(--line);z-index:2}
|
|
101
|
+
.gutter.row{width:5px;cursor:col-resize} .gutter.col{height:5px;cursor:row-resize}
|
|
102
|
+
.gutter:hover,.gutter.drag{background:var(--brand)}
|
|
103
|
+
.leaf{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;flex:1 1 0;background:var(--bg)}
|
|
104
|
+
.leaf.focus{box-shadow:inset 0 0 0 1px rgba(78,201,176,.45)}
|
|
105
|
+
.leaf.drop{box-shadow:inset 0 0 0 2px var(--brand)}
|
|
106
|
+
.leaf-h{display:flex;align-items:center;gap:8px;height:26px;padding:0 6px 0 10px;background:var(--surface);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:11px;color:var(--muted);flex:none;cursor:pointer}
|
|
107
|
+
.leaf.focus .leaf-h{background:var(--brand-dim)}
|
|
108
|
+
.leaf-h .nm{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--ink)}
|
|
109
|
+
.leaf-h .x{color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px}
|
|
110
|
+
.leaf-h .x:hover{color:var(--failed)}
|
|
111
|
+
.chip{font-size:9px;text-transform:uppercase;letter-spacing:.04em;padding:1px 6px;border-radius:999px}
|
|
91
112
|
.chip.running{color:var(--running);background:rgba(85,167,224,.14)}
|
|
92
113
|
.chip.done,.chip.merged{color:var(--done);background:rgba(88,179,104,.14)}
|
|
93
114
|
.chip.blocked,.chip.preparing,.chip.pending,.chip.starting{color:var(--blocked);background:rgba(214,162,73,.16)}
|
|
94
115
|
.chip.failed,.chip.error{color:var(--failed);background:rgba(226,91,103,.14)}
|
|
95
116
|
.chip.open{color:var(--open);background:rgba(127,156,245,.14)}
|
|
96
117
|
.chip.stopped{color:var(--stopped);background:rgba(181,139,224,.14)}
|
|
97
|
-
.pane-h .title{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--faint)}
|
|
98
|
-
.pane-h .x{color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;padding:0 3px}
|
|
99
|
-
.pane-h .x:hover{color:var(--failed)}
|
|
100
118
|
.vbadge{font-size:9px;font-weight:600;letter-spacing:.03em;padding:1px 6px;border-radius:999px;white-space:nowrap}
|
|
101
119
|
.vbadge.pass{color:var(--done);background:rgba(88,179,104,.16)}
|
|
102
120
|
.vbadge.fail{color:var(--failed);background:rgba(226,91,103,.16)}
|
|
103
121
|
.vbadge.running{color:var(--blocked);background:rgba(214,162,73,.16)}
|
|
104
122
|
.vbadge.error{color:var(--failed);background:rgba(226,91,103,.12)}
|
|
105
|
-
.
|
|
106
|
-
.
|
|
123
|
+
.leaf-body{flex:1;min-height:0;min-width:0;display:flex}
|
|
124
|
+
.leaf-body.drop{box-shadow:inset 0 0 0 2px var(--brand);background:var(--brand-dim)}
|
|
125
|
+
.leaf-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--faint);font-family:var(--mono);font-size:11.5px;text-align:center;padding:12px}
|
|
126
|
+
.term-host{flex:1;min-height:0;min-width:0;padding:4px 2px 2px 8px}
|
|
127
|
+
.term-host .xterm{height:100%}
|
|
107
128
|
|
|
108
129
|
.empty{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
|
|
109
130
|
.empty .card{max-width:440px}
|
|
@@ -133,6 +154,34 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
133
154
|
.reqgo:disabled{opacity:.4;cursor:default}
|
|
134
155
|
.reqgo.bcast{background:var(--open);color:#0b0d12}
|
|
135
156
|
.reqgo.steer{background:var(--running);color:#04121e}
|
|
157
|
+
/* ── folder picker (자유 세션 폴더 지정) ── */
|
|
158
|
+
.modal{position:fixed;inset:0;background:rgba(4,6,10,.6);display:none;align-items:center;justify-content:center;z-index:60}
|
|
159
|
+
.modal.on{display:flex}
|
|
160
|
+
.pick{width:min(560px,92vw);max-height:76vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--line-hi);border-radius:14px;overflow:hidden;font-family:var(--mono)}
|
|
161
|
+
.pick-h{display:flex;align-items:center;gap:10px;padding:13px 15px;border-bottom:1px solid var(--line)}
|
|
162
|
+
.pick-h .t{font-size:13px;color:var(--ink);font-weight:600}
|
|
163
|
+
.pick-h .x{margin-left:auto;background:none;border:none;color:var(--faint);font-size:16px;cursor:pointer}
|
|
164
|
+
.pick-h .x:hover{color:var(--ink)}
|
|
165
|
+
.pick-path{padding:8px 15px;font-size:11.5px;color:var(--brand);border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
|
|
166
|
+
.pick-list{flex:1;overflow:auto;padding:6px}
|
|
167
|
+
.pick-row{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12.5px;color:var(--muted)}
|
|
168
|
+
.pick-row:hover{background:var(--surface2);color:var(--ink)}
|
|
169
|
+
.pick-row .ic{width:14px;text-align:center;color:var(--faint)}
|
|
170
|
+
.pick-row.up .ic{color:var(--muted)}
|
|
171
|
+
.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}
|
|
172
|
+
.pick-f{display:flex;align-items:center;gap:10px;padding:12px 15px;border-top:1px solid var(--line)}
|
|
173
|
+
.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}
|
|
174
|
+
.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}
|
|
175
|
+
.pick-f .home:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
176
|
+
.pick-name{flex:1;font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 10px}
|
|
177
|
+
.pick-name:focus{outline:none;border-color:var(--brand)}
|
|
178
|
+
.pick-name::placeholder{color:var(--faint)}
|
|
179
|
+
|
|
180
|
+
.lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
|
|
181
|
+
.tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
|
|
182
|
+
.tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
|
|
183
|
+
.tnode.session .p{color:var(--faint);font-size:10.5px;overflow:hidden;text-overflow:ellipsis}
|
|
184
|
+
|
|
136
185
|
.toast{position:fixed;bottom:64px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--line-hi);color:var(--ink);
|
|
137
186
|
font-family:var(--mono);font-size:12px;padding:8px 14px;border-radius:9px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:40;max-width:80vw}
|
|
138
187
|
.toast.show{opacity:1}
|
|
@@ -186,25 +235,27 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
186
235
|
<aside class="rail">
|
|
187
236
|
<div class="lbl"><span>Workspace</span><span id="machName" style="color:var(--faint)">local</span></div>
|
|
188
237
|
<div id="tree"></div>
|
|
189
|
-
<div class="railfoot">클릭한 run
|
|
238
|
+
<div class="railfoot">클릭한 run·세션은 <b>탭</b>으로 열립니다 · split 으로 페인을 나란히 배치</div>
|
|
190
239
|
</aside>
|
|
191
240
|
|
|
192
241
|
<main class="stage">
|
|
193
|
-
<div class="
|
|
194
|
-
<
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
242
|
+
<div class="tabbar">
|
|
243
|
+
<div class="tabs" id="tabs"></div>
|
|
244
|
+
<div class="tabctl">
|
|
245
|
+
<button class="tc-btn" id="splitRow" title="세로 분할 — 포커스 페인을 좌우로" disabled>▐ split</button>
|
|
246
|
+
<button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled>▬ split</button>
|
|
247
|
+
<button class="tc-btn session" id="sessionBtn" title="자유 세션(폴더 지정 터미널) 열기">+ Session</button>
|
|
248
|
+
<button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>× pane</button>
|
|
249
|
+
</div>
|
|
199
250
|
</div>
|
|
200
251
|
<div class="panes" id="panes"></div>
|
|
201
252
|
<div class="empty" id="empty">
|
|
202
253
|
<div class="card">
|
|
203
254
|
<div class="glyph">⌗ ⌗ ⌗</div>
|
|
204
255
|
<h1>여기서 작업을 시작하세요</h1>
|
|
205
|
-
<p>직접 몰고 갈 <b>작업 세션</b>(자유 터미널)을 열거나, 아래 요청바로 에이전트를 팬아웃하세요. 트리의 <b>run</b> 을 클릭해도
|
|
256
|
+
<p>직접 몰고 갈 <b>작업 세션</b>(자유 터미널)을 열거나, 아래 요청바로 에이전트를 팬아웃하세요. 트리의 <b>run</b> 을 클릭해도 <b>탭</b>으로 열립니다.</p>
|
|
206
257
|
<button class="cta" id="sessionCta">+ 새 작업 세션 열기</button>
|
|
207
|
-
<div class="hint">세션 =
|
|
258
|
+
<div class="hint">세션 = <b>지정한 폴더</b>의 tmux 셸(특정 프로젝트에 소속되지 않음). 그 안에서 <code>claude</code> 를 띄워 “이 프로젝트 구현해줘” 처럼 직접 지시할 수 있습니다.</div>
|
|
208
259
|
</div>
|
|
209
260
|
</div>
|
|
210
261
|
<div class="reqbar">
|
|
@@ -243,6 +294,19 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
243
294
|
<div class="rv-cols" id="rvCols"></div>
|
|
244
295
|
</section>
|
|
245
296
|
|
|
297
|
+
<div class="modal" id="pickModal">
|
|
298
|
+
<div class="pick">
|
|
299
|
+
<div class="pick-h"><span class="t">세션 폴더 지정</span><button class="x" id="pickClose" title="닫기">×</button></div>
|
|
300
|
+
<div class="pick-path" id="pickPath">…</div>
|
|
301
|
+
<div class="pick-list" id="pickList"></div>
|
|
302
|
+
<div class="pick-f">
|
|
303
|
+
<button class="home" id="pickHome" title="홈으로">⌂ home</button>
|
|
304
|
+
<input class="pick-name" id="pickName" placeholder="세션 이름 (선택 — 비우면 폴더명)" autocomplete="off" />
|
|
305
|
+
<button class="go" id="pickGo">여기서 열기</button>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
246
310
|
<div class="toast" id="toast"></div>
|
|
247
311
|
|
|
248
312
|
<script src="/vendor/xterm.js"></script>
|
|
@@ -287,10 +351,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
287
351
|
if (cur){ for (var i=0;i<sel.options.length;i++){ if (sel.options[i].value===cur){ sel.value=cur; break; } } }
|
|
288
352
|
}
|
|
289
353
|
function populateReq(){
|
|
290
|
-
var repos = fleet.repos||[];
|
|
354
|
+
var repos = (fleet.repos||[]).filter(function(r){ return r.kind!=='sessions'; });
|
|
291
355
|
fillSelect($('reqRepo'), repos, function(r){return String(r.id);}, function(r){return r.name;}, true);
|
|
292
356
|
// repo 미선택 상태면 포커스 페인의 repo 로 기본
|
|
293
|
-
if (
|
|
357
|
+
var fr=(typeof focusedRunId==='function')?focusedRunId():null; if (fr!=null){ var rp = repoOfRun(fr); if (rp!=null) $('reqRepo').value=String(rp); }
|
|
294
358
|
var provs = fleet.providers||[];
|
|
295
359
|
if (provs.length) fillSelect($('reqAgent'), provs, function(p){return p.id;}, function(p){return p.label||p.id;}, true);
|
|
296
360
|
else if (!$('reqAgent').options.length){ var o=document.createElement('option'); o.value='claude-code'; o.textContent='claude-code'; $('reqAgent').appendChild(o); }
|
|
@@ -303,9 +367,29 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
303
367
|
var groupById = {}; groups.forEach(function(g){ groupById[g.id]=g; });
|
|
304
368
|
var tasksByRepo = {}; tasks.forEach(function(t){ (tasksByRepo[t.repoId]=tasksByRepo[t.repoId]||[]).push(t); });
|
|
305
369
|
var runsByTask = {}; runs.forEach(function(r){ (runsByTask[r.taskId]=runsByTask[r.taskId]||[]).push(r); });
|
|
306
|
-
|
|
370
|
+
// 세션 버킷(kind='sessions') 분리 — 프로젝트 트리와 별개 SESSIONS 섹션
|
|
371
|
+
var sessionRepoIds = {}; repos.forEach(function(r){ if (r.kind==='sessions') sessionRepoIds[r.id]=true; });
|
|
372
|
+
var realRepos = repos.filter(function(r){ return r.kind!=='sessions'; });
|
|
373
|
+
var sessRuns = [];
|
|
374
|
+
tasks.forEach(function(t){ if (sessionRepoIds[t.repoId]) (runsByTask[t.id]||[]).forEach(function(r){ sessRuns.push({run:r, title:t.title}); }); });
|
|
375
|
+
sessRuns.sort(function(a,b){ return b.run.id-a.run.id; });
|
|
376
|
+
|
|
307
377
|
var html = '';
|
|
308
|
-
|
|
378
|
+
// ── SESSIONS (자유 세션) ──
|
|
379
|
+
html += '<div class="lbl"><span>Sessions</span><span class="lnk" data-newsession="1">+ 새 세션</span></div>';
|
|
380
|
+
if (sessRuns.length){
|
|
381
|
+
sessRuns.forEach(function(s){
|
|
382
|
+
var r=s.run; var open = tabs[r.id] ? ' open' : '';
|
|
383
|
+
html += '<div class="tnode session'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
|
|
384
|
+
+ '<span class="n">'+esc(s.title||'session')+'</span><span class="p">'+esc((r.worktreePath||'').replace(/^.*\\/([^/]+\\/[^/]+)$/,'…/$1'))+'</span></div>';
|
|
385
|
+
});
|
|
386
|
+
} else {
|
|
387
|
+
html += '<div class="tnode empty" style="padding-left:14px">열린 세션 없음 — + Session 으로 폴더 지정</div>';
|
|
388
|
+
}
|
|
389
|
+
html += '<div class="tree-sep"></div>';
|
|
390
|
+
html += '<div class="lbl"><span>Projects</span></div>';
|
|
391
|
+
if (!realRepos.length){ html += '<div class="tnode empty">등록된 repo 가 없습니다 — 보드에서 추가하세요.</div>'; }
|
|
392
|
+
realRepos.forEach(function(repo){
|
|
309
393
|
var rk = 'repo'+repo.id;
|
|
310
394
|
var rTasks = (tasksByRepo[repo.id]||[]).filter(function(t){ return t.status!=='closed'; });
|
|
311
395
|
var runCount = rTasks.reduce(function(n,t){ return n+((runsByTask[t.id]||[]).length); }, 0);
|
|
@@ -331,163 +415,343 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
331
415
|
var s = '<div class="tnode task" data-fold="'+tk+'"><span class="car">'+(rns.length?(isFold(tk)?'▸':'▾'):' ')+'</span>'
|
|
332
416
|
+ '<span class="n">'+esc(t.title)+'</span></div>';
|
|
333
417
|
if (!isFold(tk)) rns.sort(function(a,b){return a.id-b.id;}).forEach(function(r){
|
|
334
|
-
var open =
|
|
418
|
+
var open = tabs[r.id] ? ' open' : '';
|
|
335
419
|
s += '<div class="tnode run'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
|
|
336
420
|
+ '<span class="n">r'+r.id+' · '+esc(r.status)+'</span></div>';
|
|
337
421
|
});
|
|
338
422
|
return s;
|
|
339
423
|
}
|
|
340
424
|
$('tree').addEventListener('click', function(e){
|
|
341
|
-
|
|
425
|
+
if (e.target.closest('[data-newsession]')){ openSession(); return; }
|
|
426
|
+
var run = e.target.closest('[data-run]');
|
|
342
427
|
if (run){ openRunPane(+run.dataset.run); return; }
|
|
343
428
|
var fn = e.target.closest('[data-fold]');
|
|
344
429
|
if (fn){ var k = fn.dataset.fold; fold[k] = !isFold(k); renderTree(); }
|
|
345
430
|
});
|
|
431
|
+
// 트리 세션 행 더블클릭 → 이름 변경
|
|
432
|
+
$('tree').addEventListener('dblclick', function(e){
|
|
433
|
+
var s = e.target.closest('.tnode.session[data-run]'); if(!s) return;
|
|
434
|
+
var runId=+s.dataset.run; var r=runById[runId]; if(!r) return;
|
|
435
|
+
var cur=(taskById[r.taskId]||{}).title||''; var v=prompt('세션 이름', cur);
|
|
436
|
+
if(v!=null && v.trim()) renameTask(r.taskId, v.trim());
|
|
437
|
+
});
|
|
346
438
|
|
|
347
|
-
// ──
|
|
348
|
-
|
|
349
|
-
var
|
|
350
|
-
var
|
|
351
|
-
var
|
|
352
|
-
var
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
function setFocus(id){
|
|
374
|
-
focusId = id;
|
|
375
|
-
panes.forEach(function(p){ p.el.classList.toggle('focus', p.id===id); });
|
|
376
|
-
$('closeBtn').disabled = !focusId;
|
|
377
|
-
var p = panes.find(function(x){return x.id===id;});
|
|
378
|
-
if (p && p.term) try{ p.term.focus(); }catch(e){}
|
|
379
|
-
if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
|
|
439
|
+
// ── 탭 + 분할 트리 페인 ──
|
|
440
|
+
// 탭 = 열린 세션/run. 탭이 xterm·WS 를 소유(안 보여도 살아있음). 슬롯(leaf)에 배치돼 표시된다.
|
|
441
|
+
var tabs = {}; // runId -> { runId, name, term, fit, ws, retry, closing, ro, host }
|
|
442
|
+
var tabOrder = []; // 탭 바 순서(runId)
|
|
443
|
+
var layout = { leaf:true, id:'L0', tab:null }; // 분할 트리 루트
|
|
444
|
+
var focusLeaf = 'L0';
|
|
445
|
+
var leafSeq = 1;
|
|
446
|
+
var MAX_LEAVES = 6;
|
|
447
|
+
var dragRunId = null;
|
|
448
|
+
|
|
449
|
+
function newLeafId(){ return 'L'+(leafSeq++); }
|
|
450
|
+
function eachLeaf(node, fn){ if(node.leaf){ fn(node); } else { eachLeaf(node.a,fn); eachLeaf(node.b,fn); } }
|
|
451
|
+
function findLeaf(id, node){ node=node||layout; if(node.leaf) return node.id===id?node:null; return findLeaf(id,node.a)||findLeaf(id,node.b); }
|
|
452
|
+
function findSplit(id, node){ node=node||layout; if(node.leaf) return null; if(node.id===id) return node; return findSplit(id,node.a)||findSplit(id,node.b); }
|
|
453
|
+
function leafOfTab(runId){ var f=null; eachLeaf(layout,function(l){ if(l.tab===runId) f=l; }); return f; }
|
|
454
|
+
function firstLeaf(){ var f=null; eachLeaf(layout,function(l){ if(!f) f=l; }); return f; }
|
|
455
|
+
function countLeaves(){ var n=0; eachLeaf(layout,function(){n++;}); return n; }
|
|
456
|
+
function focusedRunId(){ var l=findLeaf(focusLeaf); return l?l.tab:null; }
|
|
457
|
+
function focusRun(){ return focusedRunId(); } // 기존 호출부(steer/review) 호환
|
|
458
|
+
function copyInto(dst,src){ Object.keys(dst).forEach(function(k){delete dst[k];}); Object.keys(src).forEach(function(k){dst[k]=src[k];}); }
|
|
459
|
+
|
|
460
|
+
function tabName(runId){
|
|
461
|
+
var r=runById[runId]; if(!r) return 'r'+runId;
|
|
462
|
+
var task=taskById[r.taskId]; var rp=task&&repoById[task.repoId];
|
|
463
|
+
if (rp && rp.kind==='sessions') return (task&&task.title)||('session r'+runId);
|
|
464
|
+
return 'r'+runId;
|
|
380
465
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
var
|
|
386
|
-
var p = { id: ++paneSeq, runId: runId, retry: 0, closing: false };
|
|
387
|
-
var el = document.createElement('div'); el.className = 'pane'; p.el = el;
|
|
388
|
-
el.innerHTML = '<div class="pane-h"><span class="rid">r'+runId+'</span>'
|
|
389
|
-
+ '<span class="chip '+esc(r?r.status:'')+'" data-role="chip">'+esc(r?r.status:'')+'</span>'
|
|
390
|
-
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
391
|
-
+ '<span class="title" data-role="title">'+esc(r&&r.tmuxWindow||'terminal')+'</span>'
|
|
392
|
-
+ '<button class="x" title="close">×</button></div>'
|
|
393
|
-
+ '<div class="pane-term"></div>';
|
|
394
|
-
$('panes').appendChild(el);
|
|
395
|
-
el.querySelector('.pane-h').addEventListener('click', function(ev){
|
|
396
|
-
if (ev.target.closest('.x')) return; setFocus(p.id);
|
|
397
|
-
});
|
|
398
|
-
el.querySelector('.x').addEventListener('click', function(){ closePane(p.id); });
|
|
399
|
-
// xterm
|
|
400
|
-
var term = new window.Terminal({
|
|
466
|
+
|
|
467
|
+
function ensureTab(runId){
|
|
468
|
+
if (tabs[runId]) return tabs[runId];
|
|
469
|
+
var host=document.createElement('div'); host.className='term-host';
|
|
470
|
+
var term=new window.Terminal({
|
|
401
471
|
fontFamily: "ui-monospace, 'SF Mono', Menlo, Monaco, 'Apple SD Gothic Neo', 'Noto Sans KR', monospace",
|
|
402
472
|
fontSize: 12, cursorBlink: true, allowProposedApi: true, scrollback: 4000,
|
|
403
473
|
theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
|
|
404
474
|
});
|
|
405
|
-
|
|
406
|
-
p.fit = new window.FitAddon.FitAddon(); term.loadAddon(p.fit);
|
|
475
|
+
var fit=new window.FitAddon.FitAddon(); term.loadAddon(fit);
|
|
407
476
|
try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
|
|
408
|
-
term.open(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
function connectPane(p){
|
|
418
|
-
if (p.closing || !p.term) return;
|
|
419
|
-
try{ p.fit.fit(); }catch(e){}
|
|
477
|
+
// term.open 은 host 가 DOM 에 붙은 뒤(attachHosts) 최초 1회 — detached 에서 open 하면 렌더러가 안 뜬다.
|
|
478
|
+
var t={ runId:runId, name:tabName(runId), term:term, fit:fit, ws:null, retry:0, closing:false, host:host, ro:null, opened:false, connected:false };
|
|
479
|
+
term.onData(function(d){ if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i',d:d})); });
|
|
480
|
+
tabs[runId]=t; tabOrder.push(runId);
|
|
481
|
+
return t; // open·connect 는 attachHosts 에서(Phase 2 순서: open → connect)
|
|
482
|
+
}
|
|
483
|
+
function connectTab(t){
|
|
484
|
+
if (t.closing || !t.term) return;
|
|
485
|
+
try{ t.fit.fit(); }catch(e){}
|
|
420
486
|
var proto = location.protocol==='https:'?'wss':'ws';
|
|
421
|
-
var sock = new WebSocket(proto+'://'+location.host+'/ws/term/'+
|
|
422
|
-
|
|
423
|
-
sock.onopen = function(){ if (sock!==
|
|
487
|
+
var sock = new WebSocket(proto+'://'+location.host+'/ws/term/'+t.runId+'?cols='+t.term.cols+'&rows='+t.term.rows);
|
|
488
|
+
t.ws = sock;
|
|
489
|
+
sock.onopen = function(){ if (sock!==t.ws){ try{sock.close();}catch(e){} return; } t.retry=0; };
|
|
424
490
|
sock.onmessage = function(m){
|
|
425
|
-
if (sock!==
|
|
491
|
+
if (sock!==t.ws || !t.term) return;
|
|
426
492
|
try{ var d = JSON.parse(m.data);
|
|
427
|
-
if (d.t==='o')
|
|
428
|
-
else if (d.t==='err')
|
|
429
|
-
else if (d.t==='exit')
|
|
493
|
+
if (d.t==='o') t.term.write(d.d);
|
|
494
|
+
else if (d.t==='err') t.term.write('\\r\\n\\x1b[31m'+d.d+'\\x1b[0m\\r\\n');
|
|
495
|
+
else if (d.t==='exit') t.term.write('\\r\\n\\x1b[90m[session ended — 재연결 시 소생]\\x1b[0m\\r\\n');
|
|
430
496
|
}catch(e){}
|
|
431
497
|
};
|
|
432
498
|
sock.onclose = function(){
|
|
433
|
-
if (sock!==
|
|
434
|
-
var delay = Math.min(8000, 800 * Math.pow(2,
|
|
435
|
-
setTimeout(function(){
|
|
499
|
+
if (sock!==t.ws || t.closing) return;
|
|
500
|
+
var delay = Math.min(8000, 800 * Math.pow(2, t.retry++));
|
|
501
|
+
setTimeout(function(){ connectTab(t); }, delay);
|
|
436
502
|
};
|
|
437
503
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
504
|
+
|
|
505
|
+
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){} }
|
|
506
|
+
function fitAllVisible(){ eachLeaf(layout,function(l){ if(l.tab!=null && tabs[l.tab] && tabs[l.tab].host.isConnected) fitTab(tabs[l.tab]); }); }
|
|
507
|
+
|
|
508
|
+
// ── 탭 바 렌더(터미널 없음 — 언제든 안전) ──
|
|
509
|
+
function renderTabs(){
|
|
510
|
+
var shown={}; eachLeaf(layout,function(l){ if(l.tab!=null) shown[l.tab]=true; });
|
|
511
|
+
var html='';
|
|
512
|
+
tabOrder.forEach(function(runId){
|
|
513
|
+
var t=tabs[runId]; if(!t) return; var r=runById[runId];
|
|
514
|
+
html += '<div class="tab'+(shown[runId]?' shown':'')+'" draggable="true" data-tab="'+runId+'" title="더블클릭=이름변경 · 드래그=페인에 배치">'
|
|
515
|
+
+ '<span class="st '+esc(r?r.status:'')+'"></span>'
|
|
516
|
+
+ '<span class="nm">'+esc(t.name)+'</span>'
|
|
517
|
+
+ '<button class="x" title="탭 닫기(터미널 종료)">×</button></div>';
|
|
518
|
+
});
|
|
519
|
+
$('tabs').innerHTML = html;
|
|
520
|
+
}
|
|
521
|
+
function buildNode(node){
|
|
522
|
+
if (node.leaf){
|
|
523
|
+
var leaf=document.createElement('div'); leaf.className='leaf'+(node.id===focusLeaf?' focus':''); leaf.dataset.leaf=node.id;
|
|
524
|
+
var t=node.tab!=null?tabs[node.tab]:null; var r=node.tab!=null?runById[node.tab]:null;
|
|
525
|
+
var head=document.createElement('div'); head.className='leaf-h'; head.dataset.leafhead=node.id;
|
|
526
|
+
head.innerHTML = t
|
|
527
|
+
? '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
|
|
528
|
+
+ '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
|
|
529
|
+
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
530
|
+
+ '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>'
|
|
531
|
+
: '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
|
|
532
|
+
var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
|
|
533
|
+
if (!t){ var em=document.createElement('div'); em.className='leaf-empty'; em.textContent='탭을 여기로 드래그하거나 탭을 클릭하세요'; body.appendChild(em); }
|
|
534
|
+
leaf.appendChild(head); leaf.appendChild(body); return leaf;
|
|
535
|
+
}
|
|
536
|
+
var el=document.createElement('div'); el.className='node '+(node.dir==='col'?'col':'row'); el.dataset.split=node.id;
|
|
537
|
+
var a=buildNode(node.a), b=buildNode(node.b);
|
|
538
|
+
a.style.flexGrow=String(node.ratio); a.style.flexBasis='0';
|
|
539
|
+
b.style.flexGrow=String(1-node.ratio); b.style.flexBasis='0';
|
|
540
|
+
var g=document.createElement('div'); g.className='gutter '+(node.dir==='col'?'col':'row'); g.dataset.gutter=node.id;
|
|
541
|
+
el.appendChild(a); el.appendChild(g); el.appendChild(b); return el;
|
|
542
|
+
}
|
|
543
|
+
// host 를 DOM 에 붙이고, 최초 1회 open → connect(Phase 2 순서). fit 은 render 의 rAF 에서.
|
|
544
|
+
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; body.appendChild(t.host);
|
|
545
|
+
if(!t.opened){ try{ t.term.open(t.host); t.opened=true; }catch(e){} }
|
|
546
|
+
if(!t.connected){ t.connected=true; connectTab(t); }
|
|
547
|
+
}); }
|
|
548
|
+
function updateControls(){
|
|
549
|
+
var has=tabOrder.length>0; var canSplit=has && countLeaves()<MAX_LEAVES;
|
|
550
|
+
$('splitRow').disabled=!canSplit; $('splitCol').disabled=!canSplit; $('closeBtn').disabled=!has;
|
|
551
|
+
}
|
|
552
|
+
// 구조 변경 시 전체 재구성(탭 열기·닫기·분할·드롭·리사이즈완료)
|
|
553
|
+
function render(){
|
|
554
|
+
var n=tabOrder.length;
|
|
555
|
+
$('empty').style.display = n?'none':'flex';
|
|
556
|
+
$('panes').style.display = n?'flex':'none';
|
|
557
|
+
renderTabs();
|
|
558
|
+
var host=$('panes'); host.innerHTML='';
|
|
559
|
+
if (n){ host.appendChild(buildNode(layout)); attachHosts(); }
|
|
560
|
+
updateControls();
|
|
561
|
+
if (typeof reqMode!=='undefined') setMode(reqMode);
|
|
562
|
+
requestAnimationFrame(fitAllVisible);
|
|
563
|
+
setTimeout(fitAllVisible, 60); // 레이아웃 확정 후 재핏(초기 0-size 보정)
|
|
564
|
+
}
|
|
565
|
+
function setLeafFocus(id){
|
|
566
|
+
focusLeaf=id;
|
|
567
|
+
Array.prototype.forEach.call($('panes').querySelectorAll('.leaf'),function(el){ el.classList.toggle('focus', el.dataset.leaf===id); });
|
|
568
|
+
var rid=focusedRunId(); if(rid!=null && tabs[rid]) try{ tabs[rid].term.focus(); }catch(e){}
|
|
569
|
+
updateControls();
|
|
570
|
+
if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// 탭 열기 = 포커스 슬롯에 표시(강제 분할 없음). 기존 호출부(openRunPane) 호환.
|
|
574
|
+
function openTab(runId){
|
|
575
|
+
ensureTab(runId);
|
|
576
|
+
var l=leafOfTab(runId);
|
|
577
|
+
if (l){ setLeafFocus(l.id); return; }
|
|
578
|
+
var f=findLeaf(focusLeaf) || firstLeaf();
|
|
579
|
+
if (!f){ layout={leaf:true,id:'L0',tab:runId}; focusLeaf='L0'; }
|
|
580
|
+
else { f.tab=runId; focusLeaf=f.id; }
|
|
581
|
+
render(); renderTree();
|
|
582
|
+
}
|
|
583
|
+
function openRunPane(runId){ return openTab(runId); }
|
|
584
|
+
|
|
585
|
+
function splitFocused(dir){
|
|
586
|
+
if (!tabOrder.length) return;
|
|
587
|
+
if (countLeaves()>=MAX_LEAVES){ toast('페인 최대 '+MAX_LEAVES+'개'); return; }
|
|
588
|
+
var l=findLeaf(focusLeaf) || firstLeaf(); if(!l) return;
|
|
589
|
+
var keep=l.tab; var aId=l.id, bId=newLeafId();
|
|
590
|
+
delete l.tab; delete l.leaf; l.id=newLeafId(); l.split=true; l.dir=dir; l.ratio=0.5;
|
|
591
|
+
l.a={leaf:true,id:aId,tab:keep}; l.b={leaf:true,id:bId,tab:null};
|
|
592
|
+
focusLeaf=bId; render(); renderTree();
|
|
593
|
+
}
|
|
594
|
+
function closeSlot(id){
|
|
595
|
+
if (countLeaves()<=1){ var only=findLeaf(id)||firstLeaf(); if(only) only.tab=null; if(only) focusLeaf=only.id; render(); renderTree(); return; }
|
|
596
|
+
(function walk(node){
|
|
597
|
+
if(node.leaf) return false;
|
|
598
|
+
if(node.a.leaf && node.a.id===id){ copyInto(node,node.b); return true; }
|
|
599
|
+
if(node.b.leaf && node.b.id===id){ copyInto(node,node.a); return true; }
|
|
600
|
+
return walk(node.a)||walk(node.b);
|
|
601
|
+
})(layout);
|
|
602
|
+
if(!findLeaf(focusLeaf)){ var f=firstLeaf(); focusLeaf=f?f.id:'L0'; }
|
|
603
|
+
render(); renderTree();
|
|
604
|
+
}
|
|
605
|
+
function closeTab(runId){
|
|
606
|
+
var t=tabs[runId]; if(!t) return; t.closing=true;
|
|
607
|
+
try{ if(t.ws) t.ws.close(); }catch(e){}
|
|
608
|
+
try{ if(t.ro) t.ro.disconnect(); }catch(e){}
|
|
609
|
+
try{ t.term.dispose(); }catch(e){}
|
|
610
|
+
eachLeaf(layout,function(l){ if(l.tab===runId) l.tab=null; });
|
|
611
|
+
delete tabs[runId]; tabOrder=tabOrder.filter(function(x){return x!==runId;});
|
|
612
|
+
render(); renderTree();
|
|
613
|
+
}
|
|
614
|
+
// 팬아웃 — N개 탭을 만들고 자동 타일 배치(비교용)
|
|
615
|
+
function buildTiled(ids,dir){
|
|
616
|
+
if(ids.length===1) return {leaf:true,id:newLeafId(),tab:ids[0]};
|
|
617
|
+
var mid=Math.ceil(ids.length/2);
|
|
618
|
+
return {split:true,id:newLeafId(),dir:dir,ratio:mid/ids.length,
|
|
619
|
+
a:buildTiled(ids.slice(0,mid),dir==='row'?'col':'row'),
|
|
620
|
+
b:buildTiled(ids.slice(mid),dir==='row'?'col':'row')};
|
|
621
|
+
}
|
|
622
|
+
function tileTabs(ids){
|
|
623
|
+
ids=(ids||[]).filter(function(x){return x!=null;}); if(!ids.length) return;
|
|
624
|
+
ids.forEach(ensureTab);
|
|
625
|
+
layout=buildTiled(ids,'row'); var f=firstLeaf(); focusLeaf=f?f.id:'L0';
|
|
626
|
+
render(); renderTree();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// fleet 갱신 시 라벨·상태만 갱신(페인 DOM 재구성 X — 터미널 유지). 사라진 run 탭 정리.
|
|
453
630
|
function syncPanes(){
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
if
|
|
459
|
-
|
|
631
|
+
tabOrder.slice().forEach(function(runId){ if(!runById[runId]) closeTab(runId); });
|
|
632
|
+
tabOrder.forEach(function(runId){ var t=tabs[runId]; if(t) t.name=tabName(runId); });
|
|
633
|
+
renderTabs();
|
|
634
|
+
eachLeaf(layout,function(l){
|
|
635
|
+
if(l.tab==null) return; var r=runById[l.tab]; if(!r) return;
|
|
636
|
+
var head=$('panes').querySelector('[data-leafhead="'+l.id+'"]'); if(!head) return;
|
|
637
|
+
var nm=head.querySelector('.nm'); if(nm && tabs[l.tab]) nm.textContent=tabs[l.tab].name;
|
|
638
|
+
var st=head.querySelector('.st'); if(st) st.className='st '+r.status;
|
|
639
|
+
var chip=head.querySelector('[data-role=chip]'); if(chip){ chip.className='chip '+r.status; chip.textContent=r.status; }
|
|
640
|
+
var vslot=head.querySelector('[data-role=vslot]'); if(vslot) vslot.innerHTML=vbadge(r.verifyStatus);
|
|
460
641
|
});
|
|
461
642
|
}
|
|
462
|
-
$('closeBtn').addEventListener('click', function(){ if (focusId) closePane(focusId); });
|
|
463
643
|
|
|
464
|
-
//
|
|
644
|
+
// 인라인 이름 변경(탭 더블클릭)
|
|
645
|
+
function startRename(runId, tabEl){
|
|
646
|
+
var r=runById[runId]; var t=tabs[runId]; if(!r||!t) return;
|
|
647
|
+
var nm=tabEl.querySelector('.nm'); if(!nm) return;
|
|
648
|
+
var input=document.createElement('input'); input.className='ren'; input.value=t.name;
|
|
649
|
+
nm.replaceWith(input); input.focus(); input.select();
|
|
650
|
+
var done=false;
|
|
651
|
+
function finish(commit){ if(done) return; done=true;
|
|
652
|
+
var val=input.value.trim();
|
|
653
|
+
var span=document.createElement('span'); span.className='nm'; span.textContent=(commit&&val)?val:t.name;
|
|
654
|
+
input.replaceWith(span);
|
|
655
|
+
if(commit && val && val!==t.name) renameTask(r.taskId, val);
|
|
656
|
+
}
|
|
657
|
+
input.addEventListener('keydown', function(e){ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); finish(true); } else if(e.key==='Escape'){ finish(false); } });
|
|
658
|
+
input.addEventListener('blur', function(){ finish(true); });
|
|
659
|
+
input.addEventListener('click', function(e){ e.stopPropagation(); });
|
|
660
|
+
}
|
|
661
|
+
async function renameTask(taskId, title){
|
|
662
|
+
try{
|
|
663
|
+
var res=await fetch('/api/tasks/'+taskId,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({title:title})});
|
|
664
|
+
if(res.ok){ if(taskById[taskId]) taskById[taskId].title=title; toast('이름 변경 · '+title); await hydrate(); }
|
|
665
|
+
else { var j=await res.json().catch(function(){return{};}); toast('이름 변경 실패: '+(j.error||res.status)); }
|
|
666
|
+
}catch(e){ toast('이름 변경 실패: '+e); }
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ── 탭 바 이벤트 ──
|
|
670
|
+
$('tabs').addEventListener('click', function(e){
|
|
671
|
+
var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; var runId=+tabEl.dataset.tab;
|
|
672
|
+
if (e.target.closest('.x')){ closeTab(runId); return; }
|
|
673
|
+
openTab(runId);
|
|
674
|
+
});
|
|
675
|
+
$('tabs').addEventListener('dblclick', function(e){ var tabEl=e.target.closest('[data-tab]'); if(tabEl) startRename(+tabEl.dataset.tab, tabEl); });
|
|
676
|
+
$('tabs').addEventListener('dragstart', function(e){ var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; dragRunId=+tabEl.dataset.tab; tabEl.classList.add('drag'); try{ e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain', String(dragRunId)); }catch(_){} });
|
|
677
|
+
$('tabs').addEventListener('dragend', function(e){ var tabEl=e.target.closest('[data-tab]'); if(tabEl) tabEl.classList.remove('drag'); dragRunId=null; });
|
|
678
|
+
|
|
679
|
+
// ── 페인(슬롯) 이벤트: 포커스·닫기·드롭·리사이즈 ──
|
|
680
|
+
$('panes').addEventListener('click', function(e){
|
|
681
|
+
var leaf=e.target.closest('[data-leaf]'); if(!leaf) return;
|
|
682
|
+
if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
|
|
683
|
+
setLeafFocus(leaf.dataset.leaf);
|
|
684
|
+
});
|
|
685
|
+
$('panes').addEventListener('dragover', function(e){ var body=e.target.closest('[data-leafbody]'); if(body && dragRunId!=null){ e.preventDefault(); body.classList.add('drop'); } });
|
|
686
|
+
$('panes').addEventListener('dragleave', function(e){ var body=e.target.closest('[data-leafbody]'); if(body) body.classList.remove('drop'); });
|
|
687
|
+
$('panes').addEventListener('drop', function(e){
|
|
688
|
+
var body=e.target.closest('[data-leafbody]'); if(!body || dragRunId==null) return; e.preventDefault(); body.classList.remove('drop');
|
|
689
|
+
var l=findLeaf(body.dataset.leafbody); if(!l) return;
|
|
690
|
+
var rid=dragRunId; eachLeaf(layout,function(x){ if(x.tab===rid) x.tab=null; });
|
|
691
|
+
ensureTab(rid); l.tab=rid; focusLeaf=l.id; render(); renderTree();
|
|
692
|
+
});
|
|
693
|
+
$('panes').addEventListener('mousedown', function(e){
|
|
694
|
+
var g=e.target.closest('[data-gutter]'); if(!g) return; e.preventDefault();
|
|
695
|
+
var node=findSplit(g.dataset.gutter); if(!node) return;
|
|
696
|
+
var container=g.parentElement; var rect=container.getBoundingClientRect(); var horiz=node.dir==='row';
|
|
697
|
+
g.classList.add('drag');
|
|
698
|
+
function mv(ev){
|
|
699
|
+
var pos=horiz?(ev.clientX-rect.left)/rect.width:(ev.clientY-rect.top)/rect.height;
|
|
700
|
+
node.ratio=Math.max(0.1,Math.min(0.9,pos));
|
|
701
|
+
var a=container.children[0], b=container.children[2];
|
|
702
|
+
if(a&&b){ a.style.flexGrow=String(node.ratio); b.style.flexGrow=String(1-node.ratio); }
|
|
703
|
+
}
|
|
704
|
+
function up(){ document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up); g.classList.remove('drag'); fitAllVisible(); }
|
|
705
|
+
document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
|
|
706
|
+
});
|
|
707
|
+
$('splitRow').addEventListener('click', function(){ splitFocused('row'); });
|
|
708
|
+
$('splitCol').addEventListener('click', function(){ splitFocused('col'); });
|
|
709
|
+
$('closeBtn').addEventListener('click', function(){ if(focusLeaf) closeSlot(focusLeaf); });
|
|
710
|
+
|
|
711
|
+
// ── 자유 세션 — 폴더를 지정해 tmux 셸(프로젝트 비소속). ──
|
|
712
|
+
var pickPathCur = '';
|
|
713
|
+
function machineSlug(){ return (fleet.machines && fleet.machines[0] && fleet.machines[0].slug) || 'local'; }
|
|
714
|
+
function openSession(){ $('pickModal').classList.add('on'); $('pickName').value=''; browseTo(''); }
|
|
715
|
+
function closePicker(){ $('pickModal').classList.remove('on'); }
|
|
716
|
+
async function browseTo(p){
|
|
717
|
+
try{
|
|
718
|
+
var d = await (await fetch('/api/browse'+(p?('?path='+encodeURIComponent(p)):''))).json();
|
|
719
|
+
pickPathCur = d.path; $('pickPath').textContent = d.path;
|
|
720
|
+
var html = '';
|
|
721
|
+
if (d.parent && d.parent!==d.path) html += '<div class="pick-row up" data-go="'+esc(d.parent)+'"><span class="ic">↑</span><span>..</span></div>';
|
|
722
|
+
(d.dirs||[]).forEach(function(e){
|
|
723
|
+
var full = d.path==='/' ? '/'+e.name : d.path+'/'+e.name;
|
|
724
|
+
html += '<div class="pick-row" data-go="'+esc(full)+'"><span class="ic">'+(e.isRepo?'◆':'▸')+'</span>'
|
|
725
|
+
+ '<span>'+esc(e.name)+'</span>'+(e.isRepo?'<span class="rp">git</span>':'')+'</div>';
|
|
726
|
+
});
|
|
727
|
+
if (!(d.dirs||[]).length) html += '<div class="pick-row" style="cursor:default;color:var(--faint)">하위 폴더 없음 — 이 폴더에서 열 수 있습니다</div>';
|
|
728
|
+
$('pickList').innerHTML = html;
|
|
729
|
+
}catch(e){ $('pickList').innerHTML = '<div class="pick-row" style="color:var(--failed)">폴더를 읽을 수 없습니다</div>'; }
|
|
730
|
+
}
|
|
731
|
+
$('pickList').addEventListener('click', function(e){ var r=e.target.closest('[data-go]'); if (r) browseTo(r.dataset.go); });
|
|
732
|
+
$('pickClose').addEventListener('click', closePicker);
|
|
733
|
+
$('pickHome').addEventListener('click', function(){ browseTo(''); });
|
|
734
|
+
$('pickModal').addEventListener('click', function(e){ if (e.target===this) closePicker(); });
|
|
465
735
|
var openingSession = false;
|
|
466
|
-
async function
|
|
467
|
-
if (openingSession) return;
|
|
468
|
-
|
|
469
|
-
if (!repos.length){ toast('먼저 repo 를 등록하세요 — 보드(← Board)에서 Add repository'); return; }
|
|
470
|
-
var repoId = Number($('reqRepo').value) || repos[0].id;
|
|
471
|
-
var rp = repoById[repoId];
|
|
472
|
-
openingSession = true; $('sessionBtn').disabled = true;
|
|
736
|
+
$('pickGo').addEventListener('click', async function(){
|
|
737
|
+
if (openingSession || !pickPathCur) return;
|
|
738
|
+
openingSession=true; $('pickGo').disabled=true;
|
|
473
739
|
try{
|
|
474
|
-
var
|
|
475
|
-
|
|
740
|
+
var nm = $('pickName').value.trim();
|
|
741
|
+
var res = await fetch('/api/session',{method:'POST',headers:{'content-type':'application/json'},
|
|
742
|
+
body:JSON.stringify({machineSlug:machineSlug(), path:pickPathCur, title:nm})});
|
|
476
743
|
var j = await res.json().catch(function(){return{};});
|
|
477
|
-
if (res.ok && j.runId){
|
|
478
|
-
|
|
479
|
-
openRunPane(j.runId);
|
|
480
|
-
toast('세션 열림'+(rp?(' · '+rp.name+' 최상위'):'')+' — 이 터미널에서 직접 에이전트를 구동하세요');
|
|
481
|
-
} else { toast('세션 실패: '+(j.detail||j.error||res.status)); }
|
|
744
|
+
if (res.ok && j.runId){ closePicker(); await hydrate(); openRunPane(j.runId); toast('세션 열림 · '+(nm||pickPathCur)); }
|
|
745
|
+
else toast('세션 실패: '+(j.detail||j.error||res.status));
|
|
482
746
|
}catch(e){ toast('세션 실패: '+e); }
|
|
483
|
-
finally{ openingSession=false; $('
|
|
484
|
-
}
|
|
747
|
+
finally{ openingSession=false; $('pickGo').disabled=false; }
|
|
748
|
+
});
|
|
749
|
+
$('pickName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('pickGo').click(); } });
|
|
485
750
|
$('sessionBtn').addEventListener('click', openSession);
|
|
486
751
|
$('sessionCta').addEventListener('click', openSession);
|
|
487
752
|
|
|
488
753
|
// ── 요청바: New(팬아웃) / Steer / Broadcast ──
|
|
489
754
|
var reqMode = 'new';
|
|
490
|
-
function focusRun(){ if (!focusId) return null; var p = panes.find(function(x){return x.id===focusId;}); return p?p.runId:null; }
|
|
491
755
|
function setMode(m){
|
|
492
756
|
reqMode = m;
|
|
493
757
|
Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.classList.toggle('on', b.dataset.mode===m); });
|
|
@@ -499,9 +763,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
499
763
|
else if (m==='steer'){ var rid=focusRun(); go.textContent='Steer ⏎';
|
|
500
764
|
$('reqTgt').textContent = rid?('➤ r'+rid+' 에 후속 지시'):'포커스한 페인 없음';
|
|
501
765
|
inp.placeholder = rid?('r'+rid+' 에이전트에게 다음 지시…'):'왼쪽 트리에서 run 을 열어 포커스하세요'; }
|
|
502
|
-
else { go.textContent='Send ⏎'; var n=
|
|
503
|
-
$('reqTgt').textContent = '⊞ 열린
|
|
504
|
-
inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린
|
|
766
|
+
else { go.textContent='Send ⏎'; var n=tabOrder.length;
|
|
767
|
+
$('reqTgt').textContent = '⊞ 열린 탭 '+n+'개에 브로드캐스트';
|
|
768
|
+
inp.placeholder = n?('열린 '+n+'개 터미널에 그대로 전송 (엔터 포함)'):'열린 탭이 없습니다'; }
|
|
505
769
|
}
|
|
506
770
|
Array.prototype.forEach.call(document.querySelectorAll('.mode'), function(b){ b.addEventListener('click', function(){ setMode(b.dataset.mode); $('reqInput').focus(); }); });
|
|
507
771
|
|
|
@@ -523,8 +787,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
523
787
|
var ids = (rr&&rr.runs||[]).map(function(x){return x.id;});
|
|
524
788
|
inp.value='';
|
|
525
789
|
await hydrate();
|
|
526
|
-
//
|
|
527
|
-
ids
|
|
790
|
+
// 팬아웃은 비교가 목적 — N개 탭을 만들고 자동 타일 배치
|
|
791
|
+
tileTabs(ids);
|
|
528
792
|
toast(ids.length+'개 에이전트 팬아웃 · r'+ids.join(' r'));
|
|
529
793
|
}catch(e){ toast('요청 실패: '+e); }
|
|
530
794
|
finally{ submitting=false; $('reqGo').disabled=false; }
|
|
@@ -540,12 +804,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
540
804
|
}catch(e){ toast('steer 실패: '+e); }
|
|
541
805
|
finally{ submitting=false; $('reqGo').disabled=false; }
|
|
542
806
|
} else { // broadcast
|
|
543
|
-
if (!
|
|
807
|
+
if (!tabOrder.length){ toast('열린 탭이 없습니다'); return; }
|
|
544
808
|
var payload = text + '\\r';
|
|
545
809
|
var sent=0;
|
|
546
|
-
|
|
810
|
+
tabOrder.forEach(function(rid){ var t=tabs[rid]; if (t && t.ws && t.ws.readyState===1){ t.ws.send(JSON.stringify({t:'i',d:payload})); sent++; } });
|
|
547
811
|
inp.value='';
|
|
548
|
-
toast('브로드캐스트 → '+sent+'개
|
|
812
|
+
toast('브로드캐스트 → '+sent+'개 탭');
|
|
549
813
|
}
|
|
550
814
|
}
|
|
551
815
|
$('reqGo').addEventListener('click', submitReq);
|
|
@@ -559,8 +823,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
559
823
|
$('vtReview').addEventListener('click', showReview);
|
|
560
824
|
function reviewableTasks(){
|
|
561
825
|
var byTask = {}; (fleet.runs||[]).forEach(function(r){ byTask[r.taskId]=(byTask[r.taskId]||0)+1; });
|
|
562
|
-
return (fleet.tasks||[]).filter(function(t){
|
|
563
|
-
.
|
|
826
|
+
return (fleet.tasks||[]).filter(function(t){
|
|
827
|
+
var rp = repoById[t.repoId]; if (rp && rp.kind==='sessions') return false; // 세션은 리뷰 대상 아님
|
|
828
|
+
return (byTask[t.id]||0)>=1;
|
|
829
|
+
}).sort(function(a,b){ return b.id-a.id; });
|
|
564
830
|
}
|
|
565
831
|
function renderReviewPicker(){
|
|
566
832
|
var tasks = reviewableTasks(); var sel=$('rvTask');
|
|
@@ -672,10 +938,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
672
938
|
function scheduleHydrate(){ if (hydT) return; hydT = setTimeout(function(){ hydT=null; hydrate(); }, 400); }
|
|
673
939
|
|
|
674
940
|
setMode('new');
|
|
675
|
-
|
|
941
|
+
render(); // 초기 빈 상태 정합(탭 없음 → empty)
|
|
676
942
|
hydrate();
|
|
677
943
|
wsConnect();
|
|
678
|
-
window.addEventListener('resize',
|
|
944
|
+
window.addEventListener('resize', fitAllVisible);
|
|
679
945
|
</script>
|
|
680
946
|
</body>
|
|
681
947
|
</html>`;
|