@imenam/mcp-github 1.1.51 → 1.1.53

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/public/index.html CHANGED
@@ -4,1267 +4,2372 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>GitHub MCP</title>
7
- <script src="https://cdn.tailwindcss.com"></script>
8
7
  <style>
9
8
  :root {
10
9
  --bg: #0a0a0b;
10
+ --rail: #0f0f11;
11
11
  --surface: #141416;
12
12
  --surface-2: #1b1b1e;
13
+ --surface-3: #16161a;
13
14
  --border: #2a2a2e;
14
- --border-strong: #3a3a3f;
15
+ --border-strong: #33333a;
15
16
  --text: #ededef;
16
- --text-dim: #9a9aa2;
17
- --text-muted: #6b6b73;
17
+ --dim: #9a9aa2;
18
+ --muted: #6b6b73;
19
+ --faint: #4a4a52;
18
20
  --accent: #4d7cfe;
19
- --accent-hover: #6a92ff;
21
+ --accent-soft: #6a92ff;
22
+ --accent-wash: rgba(77, 124, 254, 0.10);
20
23
  --danger: #e0635e;
24
+ --danger-solid: #c9403b;
25
+ --danger-wash: rgba(224, 99, 94, 0.06);
26
+ --success: #3fb950;
27
+ --warn: #d29922;
21
28
  --radius: 8px;
29
+ --radius-lg: 10px;
30
+ --radius-xl: 14px;
31
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, sans-serif;
32
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
22
33
  }
23
34
 
24
- * {
25
- -webkit-font-smoothing: antialiased;
26
- }
35
+ * { box-sizing: border-box; -webkit-font-smoothing: antialiased; }
36
+
37
+ html, body { height: 100%; }
27
38
 
28
39
  body {
40
+ margin: 0;
29
41
  background-color: var(--bg);
30
42
  color: var(--text);
31
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
43
+ font-family: var(--sans);
44
+ font-size: 14px;
32
45
  }
33
46
 
34
- /* Layout */
35
- .app-container {
36
- display: grid;
37
- grid-template-columns: 300px 1fr;
38
- gap: 1.5rem;
39
- max-width: 1080px;
40
- margin: 0 auto;
47
+ button { font: inherit; cursor: pointer; }
48
+ input, select { font: inherit; }
49
+ [hidden] { display: none !important; }
50
+
51
+ .ic { width: 16px; height: 16px; flex-shrink: 0; }
52
+ .ic-lg { width: 18px; height: 18px; }
53
+ .ic-xl { width: 20px; height: 20px; }
54
+
55
+ /* ---------------------------------------------------------------- */
56
+ /* Barre d'application */
57
+ /* ---------------------------------------------------------------- */
58
+ .appbar {
59
+ position: sticky;
60
+ top: 0;
61
+ z-index: 30;
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 16px;
65
+ height: 56px;
66
+ padding: 0 24px;
67
+ background-color: var(--surface);
68
+ border-bottom: 1px solid var(--border);
41
69
  }
42
70
 
43
- @media (max-width: 768px) {
44
- .app-container {
45
- grid-template-columns: 1fr;
46
- }
71
+ .brand {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 10px;
75
+ font-size: 15px;
76
+ font-weight: 600;
77
+ white-space: nowrap;
47
78
  }
48
79
 
49
- /* Surfaces */
50
- .card {
51
- background-color: var(--surface);
80
+ .switcher {
81
+ display: flex;
82
+ align-items: center;
83
+ gap: 8px;
84
+ height: 32px;
85
+ padding: 0 10px 0 14px;
86
+ background-color: var(--surface-2);
52
87
  border: 1px solid var(--border);
53
88
  border-radius: var(--radius);
89
+ color: var(--text);
90
+ max-width: 260px;
54
91
  }
55
-
56
- .section-label {
57
- font-size: 0.6875rem;
58
- font-weight: 600;
59
- letter-spacing: 0.08em;
60
- text-transform: uppercase;
61
- color: var(--text-muted);
92
+ .switcher:hover { border-color: var(--border-strong); }
93
+ .switcher .prefix { color: var(--muted); font-size: 12px; }
94
+ .switcher .name {
95
+ font-size: 13px;
96
+ font-weight: 500;
97
+ overflow: hidden;
98
+ text-overflow: ellipsis;
99
+ white-space: nowrap;
62
100
  }
101
+ .switcher .ic { color: var(--muted); }
63
102
 
64
- .divider {
65
- border-top: 1px solid var(--border);
103
+ .status-pill {
104
+ display: inline-flex;
105
+ align-items: center;
106
+ gap: 8px;
107
+ height: 28px;
108
+ padding: 0 12px;
109
+ border-radius: 14px;
110
+ font-size: 12px;
111
+ font-weight: 500;
112
+ background-color: rgba(63, 185, 80, 0.10);
113
+ border: 1px solid rgba(63, 185, 80, 0.35);
114
+ color: var(--success);
115
+ }
116
+ .status-pill .dot {
117
+ width: 8px; height: 8px; border-radius: 50%;
118
+ background-color: currentColor;
66
119
  }
120
+ .status-pill.is-down {
121
+ background-color: rgba(224, 99, 94, 0.10);
122
+ border-color: rgba(224, 99, 94, 0.35);
123
+ color: var(--danger);
124
+ }
125
+
126
+ .appbar-spacer { flex: 1; }
67
127
 
68
- /* Text helpers */
69
- .text-main { color: var(--text); }
70
- .text-dim { color: var(--text-dim); }
71
- .text-muted { color: var(--text-muted); }
128
+ .dirty-note {
129
+ font-size: 12px;
130
+ color: var(--warn);
131
+ white-space: nowrap;
132
+ }
133
+ .dirty-note.is-clean { color: var(--muted); }
72
134
 
73
- /* Buttons */
135
+ /* ---------------------------------------------------------------- */
136
+ /* Boutons */
137
+ /* ---------------------------------------------------------------- */
74
138
  .btn {
75
- display: flex;
139
+ display: inline-flex;
76
140
  align-items: center;
77
141
  justify-content: center;
78
- gap: 0.5rem;
79
- width: 100%;
80
- padding: 0.55rem 0.9rem;
81
- font-size: 0.875rem;
82
- font-weight: 500;
83
- border-radius: var(--radius);
142
+ gap: 8px;
143
+ height: 32px;
144
+ padding: 0 14px;
84
145
  border: 1px solid transparent;
85
- cursor: pointer;
86
- transition: background-color .15s ease, border-color .15s ease, color .15s ease, opacity .15s ease;
87
- }
88
- .btn:disabled {
89
- opacity: 0.4;
90
- cursor: not-allowed;
146
+ border-radius: var(--radius);
147
+ font-size: 13px;
148
+ font-weight: 500;
149
+ white-space: nowrap;
150
+ transition: background-color .15s, border-color .15s, color .15s, opacity .15s;
91
151
  }
152
+ .btn:disabled { opacity: .4; cursor: not-allowed; }
153
+ .btn-lg { height: 36px; padding: 0 16px; }
92
154
 
93
- .btn-primary {
94
- background-color: var(--accent);
95
- color: #fff;
96
- }
97
- .btn-primary:not(:disabled):hover { background-color: var(--accent-hover); }
155
+ .btn-primary { background-color: var(--accent); color: #fff; font-weight: 600; }
156
+ .btn-primary:not(:disabled):hover { background-color: var(--accent-soft); }
98
157
 
99
- .btn-secondary {
100
- background-color: transparent;
101
- border-color: var(--border);
102
- color: var(--text-dim);
158
+ .btn-ghost { background-color: transparent; border-color: var(--border); color: var(--dim); }
159
+ .btn-ghost:not(:disabled):hover { background-color: var(--surface-2); border-color: var(--border-strong); color: var(--text); }
160
+
161
+ .btn-soft {
162
+ background-color: var(--accent-wash);
163
+ border-color: rgba(77, 124, 254, 0.45);
164
+ color: var(--accent-soft);
103
165
  }
104
- .btn-secondary:not(:disabled):hover {
105
- background-color: var(--surface-2);
106
- border-color: var(--border-strong);
107
- color: var(--text);
166
+ .btn-soft:not(:disabled):hover { background-color: rgba(77, 124, 254, 0.18); }
167
+
168
+ .btn-danger { background-color: var(--danger-solid); color: #fff; font-weight: 600; }
169
+ .btn-danger:not(:disabled):hover { background-color: #d64b46; }
170
+
171
+ /* ---------------------------------------------------------------- */
172
+ /* Ossature */
173
+ /* ---------------------------------------------------------------- */
174
+ .shell {
175
+ display: grid;
176
+ grid-template-columns: 260px minmax(0, 1fr);
177
+ min-height: calc(100vh - 56px);
108
178
  }
109
179
 
110
- .btn-danger {
111
- background-color: transparent;
112
- border-color: var(--border);
113
- color: var(--danger);
180
+ .rail {
181
+ display: flex;
182
+ flex-direction: column;
183
+ padding: 24px 12px 16px;
184
+ background-color: var(--rail);
185
+ border-right: 1px solid var(--border);
114
186
  }
115
- .btn-danger:not(:disabled):hover {
116
- background-color: rgba(224, 99, 94, 0.08);
117
- border-color: var(--danger);
187
+
188
+ .rail-label {
189
+ padding: 0 12px;
190
+ margin-bottom: 8px;
191
+ font-size: 11px;
192
+ font-weight: 600;
193
+ letter-spacing: .08em;
194
+ text-transform: uppercase;
195
+ color: var(--muted);
118
196
  }
119
197
 
120
- .btn-icon {
121
- display: inline-flex;
198
+ .nav-item {
199
+ position: relative;
200
+ display: flex;
122
201
  align-items: center;
123
- justify-content: center;
124
- width: 2.25rem;
125
- padding: 0;
126
- flex-shrink: 0;
202
+ gap: 12px;
203
+ width: 100%;
204
+ padding: 10px 12px;
205
+ background-color: transparent;
206
+ border: none;
207
+ border-radius: var(--radius);
208
+ text-align: left;
209
+ color: var(--dim);
210
+ transition: background-color .15s;
127
211
  }
128
-
129
- /* Form fields */
130
- .field-label {
212
+ .nav-item:hover { background-color: rgba(255, 255, 255, 0.03); }
213
+ .nav-item .ic { color: var(--muted); }
214
+ .nav-item .nav-title { display: block; font-size: 13.5px; font-weight: 600; line-height: 18px; }
215
+ .nav-item .nav-summary {
131
216
  display: block;
132
- font-size: 0.75rem;
133
- font-weight: 500;
134
- color: var(--text-dim);
135
- margin-bottom: 0.4rem;
217
+ font-size: 11.5px;
218
+ line-height: 16px;
219
+ color: var(--muted);
220
+ overflow: hidden;
221
+ text-overflow: ellipsis;
222
+ white-space: nowrap;
223
+ }
224
+ .nav-item.is-active { background-color: var(--accent-wash); }
225
+ .nav-item.is-active .nav-title { color: var(--text); }
226
+ .nav-item.is-active .nav-summary { color: var(--accent-soft); }
227
+ .nav-item.is-active .ic { color: var(--accent-soft); }
228
+ .nav-item.is-active::before {
229
+ content: "";
230
+ position: absolute;
231
+ left: 0; top: 10px; bottom: 10px;
232
+ width: 3px;
233
+ border-radius: 2px;
234
+ background-color: var(--accent);
235
+ }
236
+ .nav-item .nav-text { display: block; min-width: 0; }
237
+
238
+ /* Les libellés empilés vivent dans des <span> : sans display:block ils
239
+ se suivraient sur une seule ligne dès qu'ils tiennent en largeur. */
240
+ .item-text, .item-title, .item-subtitle,
241
+ .toast-text, .toast-title, .toast-detail,
242
+ .empty-text, .empty-title, .empty-help,
243
+ .repo-info, .pick-label { display: block; }
244
+
245
+ .rail-divider {
246
+ height: 1px;
247
+ margin: 16px 12px;
248
+ background-color: var(--border);
136
249
  }
137
250
 
138
- .field-input,
139
- .field-select {
251
+ .rail-action {
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 12px;
140
255
  width: 100%;
141
- padding: 0.5rem 0.75rem;
142
- font-size: 0.875rem;
143
- background-color: var(--bg);
144
- border: 1px solid var(--border);
256
+ padding: 10px 12px;
257
+ background: transparent;
258
+ border: none;
145
259
  border-radius: var(--radius);
146
260
  color: var(--text);
147
- outline: none;
148
- transition: border-color .15s ease;
261
+ font-size: 13px;
262
+ text-align: left;
149
263
  }
150
- .field-input::placeholder { color: var(--text-muted); }
151
- .field-input:focus,
152
- .field-select:focus { border-color: var(--accent); }
153
- .field-select { cursor: pointer; }
264
+ .rail-action .ic { color: var(--dim); }
265
+ .rail-action:not(:disabled):hover { background-color: rgba(255, 255, 255, 0.03); }
266
+ .rail-action:disabled { opacity: .4; cursor: not-allowed; }
154
267
 
155
- /* Toggle rows */
156
- .toggle-row {
268
+ .rail-spacer { flex: 1; min-height: 24px; }
269
+
270
+ .danger-zone {
271
+ padding: 12px 8px 8px;
272
+ background-color: var(--danger-wash);
273
+ border: 1px solid rgba(224, 99, 94, 0.22);
274
+ border-radius: var(--radius);
275
+ }
276
+ .danger-zone .zone-label {
157
277
  display: flex;
158
278
  align-items: center;
159
- justify-content: space-between;
160
- gap: 1rem;
161
- padding: 0.65rem 0;
279
+ gap: 8px;
280
+ padding: 0 8px 8px;
281
+ font-size: 11px;
282
+ font-weight: 600;
283
+ letter-spacing: .08em;
284
+ text-transform: uppercase;
285
+ color: var(--danger);
162
286
  }
163
- .toggle-row + .toggle-row { border-top: 1px solid var(--border); }
164
- .toggle-row label.text {
165
- font-size: 0.875rem;
166
- color: var(--text-dim);
287
+ .danger-zone .zone-label .ic { width: 14px; height: 14px; }
288
+ .danger-action {
289
+ display: block;
290
+ width: 100%;
291
+ padding: 8px;
292
+ background: transparent;
293
+ border: none;
294
+ border-radius: 6px;
295
+ text-align: left;
296
+ font-size: 12.5px;
297
+ color: var(--danger);
167
298
  }
299
+ .danger-action:not(:disabled):hover { background-color: rgba(224, 99, 94, 0.10); }
300
+ .danger-action:disabled { opacity: .4; cursor: not-allowed; }
168
301
 
169
- .info-row {
170
- display: flex;
171
- align-items: center;
172
- justify-content: space-between;
173
- gap: 1rem;
174
- padding: 0.65rem 0.75rem;
175
- background-color: var(--bg);
302
+ /* ---------------------------------------------------------------- */
303
+ /* Contenu */
304
+ /* ---------------------------------------------------------------- */
305
+ .content { padding: 32px 40px 64px; min-width: 0; }
306
+
307
+ .page-title { margin: 0; font-size: 21px; font-weight: 600; }
308
+ .page-subtitle { margin: 8px 0 0; font-size: 13px; color: var(--muted); }
309
+
310
+ .layout {
311
+ display: grid;
312
+ grid-template-columns: minmax(0, 1fr) 260px;
313
+ gap: 40px;
314
+ margin-top: 32px;
315
+ align-items: start;
316
+ }
317
+ .layout.is-single { grid-template-columns: minmax(0, 1fr); }
318
+ .col-main { display: flex; flex-direction: column; gap: 24px; min-width: 0; }
319
+ .col-side { display: flex; flex-direction: column; gap: 24px; }
320
+
321
+ .card {
322
+ background-color: var(--surface);
176
323
  border: 1px solid var(--border);
177
- border-radius: var(--radius);
324
+ border-radius: var(--radius-lg);
325
+ padding: 24px;
178
326
  }
327
+ .card-title { margin: 0; font-size: 14px; font-weight: 600; }
328
+ .card-help { margin: 6px 0 0; font-size: 12px; color: var(--muted); }
179
329
 
180
- /* Collapsible form sections */
181
- .form-section {
330
+ .side-label {
331
+ font-size: 11px;
332
+ font-weight: 600;
333
+ letter-spacing: .08em;
334
+ text-transform: uppercase;
335
+ color: var(--muted);
336
+ }
337
+
338
+ /* Carte dépôt */
339
+ .repo-row { display: flex; align-items: center; gap: 16px; }
340
+ .repo-avatar {
341
+ display: flex;
342
+ align-items: center;
343
+ justify-content: center;
344
+ width: 56px; height: 56px;
345
+ flex-shrink: 0;
346
+ background-color: var(--surface-2);
182
347
  border: 1px solid var(--border);
183
- border-radius: var(--radius);
348
+ border-radius: var(--radius-lg);
349
+ color: var(--accent-soft);
350
+ }
351
+ .repo-avatar .ic { width: 26px; height: 26px; }
352
+ .repo-info { flex: 1; min-width: 0; }
353
+ .repo-name {
354
+ font-size: 16px;
355
+ font-weight: 600;
184
356
  overflow: hidden;
357
+ text-overflow: ellipsis;
358
+ white-space: nowrap;
185
359
  }
186
- .section-header {
360
+ .repo-meta {
187
361
  display: flex;
188
362
  align-items: center;
189
- justify-content: space-between;
190
- width: 100%;
191
- padding: 0.7rem 0.9rem;
363
+ gap: 10px;
364
+ margin-top: 6px;
365
+ font-size: 12px;
366
+ color: var(--muted);
367
+ flex-wrap: wrap;
368
+ }
369
+ .chip-static {
370
+ display: inline-flex;
371
+ align-items: center;
372
+ gap: 6px;
373
+ height: 22px;
374
+ padding: 0 10px;
375
+ border-radius: 11px;
192
376
  background-color: var(--surface-2);
193
- border: none;
194
- cursor: pointer;
195
- font-size: 0.8125rem;
196
- font-weight: 600;
197
- letter-spacing: 0.02em;
377
+ border: 1px solid var(--border);
378
+ color: var(--dim);
379
+ font-size: 11px;
380
+ font-weight: 500;
381
+ }
382
+ .chip-static .ic { width: 12px; height: 12px; }
383
+ .lang-dot { width: 8px; height: 8px; border-radius: 50%; background-color: var(--dim); }
384
+
385
+ .empty-state { display: flex; align-items: center; gap: 16px; }
386
+ .empty-state .empty-text { flex: 1; }
387
+ .empty-state .empty-title { font-size: 14px; font-weight: 600; }
388
+ .empty-state .empty-help { margin-top: 4px; font-size: 12px; color: var(--muted); }
389
+
390
+ /* Flux de branches */
391
+ .branch-flow {
392
+ display: flex;
393
+ align-items: stretch;
394
+ gap: 16px;
395
+ margin-top: 20px;
396
+ }
397
+ .branch-arrow {
398
+ display: flex;
399
+ align-items: center;
400
+ color: var(--faint);
401
+ }
402
+ .branch-picker {
403
+ flex: 1;
404
+ min-width: 0;
405
+ display: block;
406
+ padding: 12px 14px;
407
+ background-color: var(--rail);
408
+ border: 1px solid var(--border);
409
+ border-radius: var(--radius);
410
+ text-align: left;
198
411
  color: var(--text);
199
- transition: background-color .15s ease;
412
+ transition: border-color .15s, background-color .15s;
200
413
  }
201
- .section-header:hover { background-color: var(--border); }
202
- .section-header .chevron { transition: transform .2s ease; color: var(--text-muted); }
203
- .section-header.open .chevron { transform: rotate(90deg); }
204
- .section-static-header {
205
- padding: 0.7rem 0.9rem;
206
- background-color: var(--surface-2);
207
- font-size: 0.8125rem;
414
+ .branch-picker:not(:disabled):hover { border-color: var(--border-strong); }
415
+ .branch-picker:disabled { opacity: .5; cursor: not-allowed; }
416
+ .branch-picker.is-target { border-color: var(--accent); }
417
+ .branch-picker .pick-label {
418
+ font-size: 10.5px;
208
419
  font-weight: 600;
209
- letter-spacing: 0.02em;
210
- color: var(--text);
420
+ letter-spacing: .08em;
421
+ text-transform: uppercase;
422
+ color: var(--muted);
211
423
  }
212
- .section-body { padding: 1rem 0.9rem; }
424
+ .branch-picker.is-target .pick-label { color: var(--accent-soft); }
425
+ .branch-picker .pick-value {
426
+ display: flex;
427
+ align-items: center;
428
+ gap: 8px;
429
+ margin-top: 6px;
430
+ font-size: 14.5px;
431
+ font-weight: 500;
432
+ min-width: 0;
433
+ }
434
+ .branch-picker .pick-value .name {
435
+ overflow: hidden;
436
+ text-overflow: ellipsis;
437
+ white-space: nowrap;
438
+ }
439
+ .branch-picker .pick-value .ic { color: var(--dim); }
440
+ .branch-picker .pick-value .chevrons { margin-left: auto; color: var(--muted); }
441
+ .branch-picker .placeholder { color: var(--muted); font-weight: 400; }
213
442
 
214
- /* iPhone style slider (sober) */
215
- .switch {
216
- position: relative;
217
- display: inline-block;
218
- width: 40px;
443
+ .badge-new {
444
+ display: inline-flex;
445
+ align-items: center;
219
446
  height: 22px;
220
- flex-shrink: 0;
447
+ padding: 0 10px;
448
+ border-radius: 11px;
449
+ background-color: rgba(77, 124, 254, 0.14);
450
+ color: var(--accent-soft);
451
+ font-size: 11px;
452
+ font-weight: 500;
221
453
  }
454
+
455
+ .flow-hint { margin: 16px 0 0; font-size: 12px; color: var(--muted); }
456
+
457
+ /* Lignes à bascule */
458
+ .toggle-row {
459
+ display: flex;
460
+ align-items: center;
461
+ gap: 20px;
462
+ padding: 16px 0;
463
+ }
464
+ .toggle-row + .toggle-row { border-top: 1px solid var(--border); }
465
+ .toggle-row:first-of-type { padding-top: 4px; }
466
+ .toggle-row .toggle-text { flex: 1; min-width: 0; }
467
+ .toggle-row .toggle-title { font-size: 13.5px; font-weight: 500; }
468
+ .toggle-row .toggle-help { margin-top: 4px; font-size: 12px; color: var(--muted); }
469
+
470
+ .switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
222
471
  .switch input { opacity: 0; width: 0; height: 0; }
223
- .slider {
472
+ .switch .slider {
224
473
  position: absolute;
225
- cursor: pointer;
226
474
  inset: 0;
475
+ cursor: pointer;
227
476
  background-color: var(--border-strong);
228
- transition: .25s;
229
477
  border-radius: 22px;
478
+ transition: background-color .2s;
230
479
  }
231
- .slider:before {
232
- position: absolute;
480
+ .switch .slider::before {
233
481
  content: "";
234
- height: 16px;
235
- width: 16px;
236
- left: 3px;
237
- bottom: 3px;
482
+ position: absolute;
483
+ height: 16px; width: 16px;
484
+ left: 3px; bottom: 3px;
238
485
  background-color: #fff;
239
- transition: .25s;
240
486
  border-radius: 50%;
487
+ transition: transform .2s;
241
488
  }
242
- input:checked + .slider { background-color: var(--accent); }
243
- input:checked + .slider:before { transform: translateX(18px); }
489
+ .switch input:checked + .slider { background-color: var(--accent); }
490
+ .switch input:checked + .slider::before { transform: translateX(18px); }
491
+ .switch input:focus-visible + .slider { outline: 2px solid var(--accent-soft); outline-offset: 2px; }
244
492
 
245
- /* Custom dropdown */
246
- .search-results {
247
- position: absolute;
248
- top: 100%;
249
- left: 0;
250
- right: 0;
251
- z-index: 50;
252
- background-color: var(--surface-2);
493
+ /* Sélecteur segmenté */
494
+ .segmented {
495
+ display: grid;
496
+ grid-template-columns: 1fr 1fr;
497
+ gap: 3px;
498
+ width: 440px;
499
+ max-width: 100%;
500
+ padding: 3px;
501
+ margin-top: 16px;
502
+ background-color: var(--rail);
253
503
  border: 1px solid var(--border);
254
- border-radius: var(--radius);
255
- margin-top: 0.25rem;
256
- max-height: 15rem;
257
- overflow-y: auto;
258
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
504
+ border-radius: var(--radius-lg);
259
505
  }
260
- .search-result-item {
261
- padding: 0.5rem 0.75rem;
262
- cursor: pointer;
263
- transition: background-color .12s;
264
- font-size: 0.875rem;
265
- color: var(--text-dim);
506
+ .segmented button {
507
+ display: flex;
508
+ align-items: center;
509
+ justify-content: center;
510
+ gap: 8px;
511
+ height: 34px;
512
+ background: transparent;
513
+ border: none;
514
+ border-radius: 8px;
515
+ font-size: 13px;
516
+ font-weight: 500;
517
+ color: var(--muted);
518
+ transition: background-color .15s, color .15s;
266
519
  }
267
- .search-result-item:hover {
268
- background-color: var(--surface);
520
+ .segmented button:hover { color: var(--dim); }
521
+ .segmented button.is-active { background-color: var(--border); color: var(--text); }
522
+
523
+ /* Champs */
524
+ .field-grid { display: flex; gap: 16px; flex-wrap: wrap; }
525
+ .field { min-width: 0; }
526
+ .field-label {
527
+ display: block;
528
+ margin-bottom: 6px;
529
+ font-size: 10.5px;
530
+ font-weight: 600;
531
+ letter-spacing: .08em;
532
+ text-transform: uppercase;
533
+ color: var(--muted);
534
+ }
535
+ .input {
536
+ width: 100%;
537
+ height: 36px;
538
+ padding: 0 14px;
539
+ background-color: var(--rail);
540
+ border: 1px solid var(--border);
541
+ border-radius: var(--radius);
269
542
  color: var(--text);
543
+ outline: none;
544
+ transition: border-color .15s;
270
545
  }
271
- .search-result-item.active {
272
- background-color: var(--surface);
546
+ .input::placeholder { color: var(--faint); }
547
+ .input:focus { border-color: var(--accent); }
548
+ .input.is-mono { font-family: var(--mono); font-size: 13.5px; }
549
+
550
+ .input-with-button { display: flex; gap: 8px; }
551
+ .input-with-button .input { flex: 1; }
552
+
553
+ .test-row {
554
+ display: flex;
555
+ align-items: center;
556
+ gap: 16px;
557
+ margin-top: 20px;
558
+ flex-wrap: wrap;
559
+ }
560
+ .test-result {
561
+ display: inline-flex;
562
+ align-items: center;
563
+ gap: 8px;
564
+ font-size: 12.5px;
565
+ }
566
+ .test-result.is-ok { color: var(--success); }
567
+ .test-result.is-ko { color: var(--danger); }
568
+ .test-result.is-pending { color: var(--dim); }
569
+
570
+ .cmd-preview {
571
+ margin: 16px 0 0;
572
+ font-family: var(--mono);
573
+ font-size: 11.5px;
574
+ line-height: 18px;
575
+ color: var(--muted);
576
+ word-break: break-all;
577
+ }
578
+
579
+ /* Résumé */
580
+ .summary-row { padding: 14px 0; }
581
+ .summary-row + .summary-row { border-top: 1px solid var(--border); }
582
+ .summary-row:first-of-type { padding-top: 8px; }
583
+ .summary-row .sum-label { font-size: 11.5px; color: var(--muted); }
584
+ .summary-row .sum-value {
585
+ margin-top: 4px;
586
+ font-size: 13px;
587
+ overflow-wrap: anywhere;
588
+ }
589
+
590
+ .notice {
591
+ display: flex;
592
+ align-items: center;
593
+ gap: 10px;
594
+ margin-top: 16px;
595
+ padding: 9px 12px;
596
+ border-radius: var(--radius);
597
+ font-size: 12px;
598
+ font-weight: 500;
599
+ background-color: rgba(210, 153, 34, 0.08);
600
+ border: 1px solid rgba(210, 153, 34, 0.30);
601
+ color: var(--warn);
602
+ }
603
+ .notice .ic { width: 14px; height: 14px; }
604
+
605
+ /* Journal d'activité */
606
+ .log-entry {
607
+ display: flex;
608
+ align-items: center;
609
+ gap: 12px;
610
+ padding: 14px 0;
611
+ font-size: 13px;
612
+ }
613
+ .log-entry + .log-entry { border-top: 1px solid var(--border); }
614
+ .log-entry:first-of-type { padding-top: 8px; }
615
+ .log-entry .log-label { flex: 1; min-width: 0; overflow-wrap: anywhere; }
616
+ .log-entry .log-time { font-size: 11.5px; color: var(--muted); white-space: nowrap; }
617
+ .log-entry.is-ok .ic { color: var(--success); }
618
+ .log-entry.is-ko .ic { color: var(--danger); }
619
+ .log-entry.is-info .ic { color: var(--accent-soft); }
620
+ .log-entry.is-neutral .ic { color: var(--dim); }
621
+ .log-empty { padding: 8px 0; font-size: 12.5px; color: var(--muted); }
622
+
623
+ /* ---------------------------------------------------------------- */
624
+ /* Palette et modale */
625
+ /* ---------------------------------------------------------------- */
626
+ .overlay {
627
+ position: fixed;
628
+ inset: 0;
629
+ z-index: 60;
630
+ display: flex;
631
+ justify-content: center;
632
+ background-color: rgba(4, 4, 6, 0.74);
633
+ }
634
+ .overlay.overlay-top { align-items: flex-start; padding-top: 120px; }
635
+ .overlay.overlay-center { align-items: center; }
636
+
637
+ .palette {
638
+ display: flex;
639
+ flex-direction: column;
640
+ width: 680px;
641
+ max-width: calc(100vw - 48px);
642
+ max-height: 560px;
643
+ background-color: var(--surface-3);
644
+ border: 1px solid var(--border-strong);
645
+ border-radius: var(--radius-xl);
646
+ box-shadow: 0 24px 64px rgba(0, 0, 0, .6);
647
+ overflow: hidden;
648
+ }
649
+
650
+ .palette-search {
651
+ display: flex;
652
+ align-items: center;
653
+ gap: 12px;
654
+ padding: 0 24px;
655
+ height: 64px;
656
+ border-bottom: 1px solid var(--border);
657
+ }
658
+ .palette-search .ic { color: var(--muted); }
659
+ .palette-search input {
660
+ flex: 1;
661
+ background: transparent;
662
+ border: none;
663
+ outline: none;
273
664
  color: var(--text);
665
+ font-size: 16px;
274
666
  }
275
- .search-result-item.selected {
276
- background-color: rgba(77, 124, 254, 0.12);
277
- color: var(--accent-hover);
667
+ .palette-search input::placeholder { color: var(--faint); }
668
+ .kbd {
669
+ padding: 4px 8px;
670
+ background-color: #1f1f24;
671
+ border: 1px solid var(--border-strong);
672
+ border-radius: 6px;
673
+ font-size: 11px;
674
+ color: var(--muted);
278
675
  }
279
676
 
280
- /* Badge */
281
- .badge {
677
+ .palette-filters {
678
+ display: flex;
679
+ align-items: center;
680
+ gap: 8px;
681
+ padding: 14px 24px;
682
+ border-bottom: 1px solid var(--border);
683
+ }
684
+ .chip {
685
+ height: 28px;
686
+ padding: 0 14px;
687
+ background: transparent;
688
+ border: 1px solid var(--border-strong);
689
+ border-radius: 14px;
690
+ color: var(--dim);
691
+ font-size: 12px;
692
+ }
693
+ .chip:hover { color: var(--text); }
694
+ .chip.is-on { background-color: var(--border); border-color: var(--border); color: var(--text); font-weight: 500; }
695
+ .palette-count { margin-left: auto; font-size: 12px; color: var(--muted); }
696
+
697
+ .palette-list { flex: 1; overflow-y: auto; padding: 12px; }
698
+ .palette-group-label {
699
+ padding: 8px 12px 6px;
700
+ font-size: 10.5px;
701
+ font-weight: 600;
702
+ letter-spacing: .08em;
703
+ text-transform: uppercase;
704
+ color: var(--muted);
705
+ }
706
+ .palette-item {
707
+ display: flex;
708
+ align-items: center;
709
+ gap: 14px;
710
+ width: 100%;
711
+ padding: 10px 12px;
712
+ background: transparent;
713
+ border: none;
714
+ border-radius: var(--radius-lg);
715
+ text-align: left;
716
+ color: var(--text);
717
+ }
718
+ .palette-item .item-icon {
719
+ display: flex;
720
+ align-items: center;
721
+ justify-content: center;
722
+ width: 32px; height: 32px;
723
+ flex-shrink: 0;
724
+ background-color: #1f1f24;
725
+ border-radius: var(--radius);
726
+ color: var(--muted);
727
+ }
728
+ .palette-item .item-text { flex: 1; min-width: 0; }
729
+ .palette-item .item-title {
730
+ font-size: 14px;
731
+ font-weight: 500;
732
+ overflow: hidden;
733
+ text-overflow: ellipsis;
734
+ white-space: nowrap;
735
+ }
736
+ .palette-item .item-subtitle {
737
+ margin-top: 2px;
738
+ font-size: 11.5px;
739
+ color: var(--muted);
740
+ overflow: hidden;
741
+ text-overflow: ellipsis;
742
+ white-space: nowrap;
743
+ }
744
+ .palette-item .item-badge {
282
745
  display: inline-flex;
283
746
  align-items: center;
284
- padding: 0.15rem 0.6rem;
285
- font-size: 0.75rem;
747
+ height: 24px;
748
+ padding: 0 12px;
749
+ border-radius: 12px;
750
+ font-size: 11px;
751
+ background-color: #1f1f24;
752
+ border: 1px solid var(--border-strong);
753
+ color: var(--dim);
754
+ }
755
+ .palette-item.is-current .item-icon { color: var(--accent-soft); }
756
+ .palette-item.is-current .item-badge {
757
+ background-color: rgba(77, 124, 254, 0.16);
758
+ border-color: transparent;
759
+ color: var(--accent-soft);
286
760
  font-weight: 500;
287
- color: var(--text-dim);
288
- background-color: var(--surface-2);
761
+ }
762
+ .palette-item.is-active { background-color: var(--accent-wash); }
763
+ .palette-item.is-create .item-icon { color: var(--accent-soft); }
764
+
765
+ .palette-empty { padding: 32px 12px; text-align: center; font-size: 13px; color: var(--muted); }
766
+
767
+ .palette-footer {
768
+ display: flex;
769
+ align-items: center;
770
+ gap: 16px;
771
+ padding: 16px 24px;
772
+ border-top: 1px solid var(--border);
773
+ font-size: 11.5px;
774
+ color: var(--muted);
775
+ }
776
+ .palette-footer .right { margin-left: auto; }
777
+
778
+ .modal {
779
+ width: 540px;
780
+ max-width: calc(100vw - 48px);
781
+ padding: 28px;
782
+ background-color: var(--surface-3);
783
+ border: 1px solid var(--border-strong);
784
+ border-radius: var(--radius-xl);
785
+ box-shadow: 0 24px 64px rgba(0, 0, 0, .6);
786
+ }
787
+ .modal-head { display: flex; align-items: center; gap: 14px; }
788
+ .modal-icon {
789
+ display: flex;
790
+ align-items: center;
791
+ justify-content: center;
792
+ width: 40px; height: 40px;
793
+ flex-shrink: 0;
794
+ border-radius: 20px;
795
+ background-color: var(--accent-wash);
796
+ color: var(--accent-soft);
797
+ }
798
+ .modal-icon.is-danger { background-color: rgba(224, 99, 94, 0.12); color: var(--danger); }
799
+ .modal-title { font-size: 16px; font-weight: 600; }
800
+ .modal-body { margin-top: 16px; font-size: 13px; line-height: 20px; color: var(--dim); }
801
+ .modal-path {
802
+ margin-top: 16px;
803
+ padding: 10px 14px;
804
+ background-color: var(--bg);
289
805
  border: 1px solid var(--border);
290
- border-radius: 999px;
806
+ border-radius: var(--radius);
807
+ font-family: var(--mono);
808
+ font-size: 12.5px;
809
+ overflow-wrap: anywhere;
810
+ }
811
+ .modal-field { margin-top: 20px; }
812
+ .modal-field label { display: block; margin-bottom: 6px; font-size: 11.5px; color: var(--muted); }
813
+ .modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
814
+
815
+ /* ---------------------------------------------------------------- */
816
+ /* Toasts */
817
+ /* ---------------------------------------------------------------- */
818
+ #toasts {
819
+ position: fixed;
820
+ right: 24px;
821
+ bottom: 24px;
822
+ z-index: 80;
823
+ display: flex;
824
+ flex-direction: column;
825
+ gap: 12px;
826
+ max-width: calc(100vw - 48px);
827
+ }
828
+ .toast {
829
+ display: flex;
830
+ align-items: center;
831
+ gap: 14px;
832
+ width: 440px;
833
+ max-width: 100%;
834
+ padding: 12px 20px;
835
+ background-color: var(--surface-2);
836
+ border: 1px solid var(--border-strong);
837
+ border-radius: var(--radius-lg);
838
+ box-shadow: 0 12px 32px rgba(0, 0, 0, .5);
839
+ animation: slide-in .22s ease-out;
840
+ }
841
+ .toast.is-error { border-color: rgba(224, 99, 94, 0.40); }
842
+ .toast .toast-text { flex: 1; min-width: 0; }
843
+ .toast .toast-title { font-size: 13px; font-weight: 600; }
844
+ .toast .toast-detail { margin-top: 3px; font-size: 11.5px; color: var(--muted); }
845
+ .toast .toast-action {
846
+ background: none;
847
+ border: none;
848
+ padding: 0;
849
+ font-size: 12.5px;
850
+ font-weight: 600;
851
+ color: var(--accent-soft);
852
+ white-space: nowrap;
853
+ }
854
+ .toast .toast-close { background: none; border: none; padding: 0; color: var(--muted); display: flex; }
855
+ .toast.is-ok .toast-icon { color: var(--success); }
856
+ .toast.is-error .toast-icon { color: var(--danger); }
857
+ .toast.is-info .toast-icon { color: var(--accent-soft); }
858
+
859
+ @keyframes slide-in {
860
+ from { transform: translateX(110%); opacity: 0; }
861
+ to { transform: translateX(0); opacity: 1; }
291
862
  }
292
863
 
293
- /* Spinner */
294
864
  .spinner {
295
865
  display: inline-block;
296
- width: 1rem;
297
- height: 1rem;
866
+ width: 16px; height: 16px;
298
867
  border: 2px solid var(--border-strong);
299
868
  border-top-color: var(--accent);
300
869
  border-radius: 50%;
301
- animation: spin 0.7s linear infinite;
870
+ animation: spin .7s linear infinite;
871
+ flex-shrink: 0;
302
872
  }
303
873
  @keyframes spin { to { transform: rotate(360deg); } }
304
874
 
305
- /* Toasts */
306
- .toast {
307
- display: flex;
308
- align-items: center;
309
- gap: 0.6rem;
310
- padding: 0.7rem 0.9rem;
311
- font-size: 0.875rem;
312
- background-color: var(--surface-2);
313
- border: 1px solid var(--border);
314
- border-left-width: 3px;
315
- border-radius: var(--radius);
316
- color: var(--text);
317
- max-width: 22rem;
318
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
319
- animation: slideIn 0.25s ease-out forwards, fadeOut 0.3s ease-in forwards 4.7s;
875
+ .skeleton {
876
+ background: linear-gradient(90deg, var(--surface-2) 25%, #232328 37%, var(--surface-2) 63%);
877
+ background-size: 400% 100%;
878
+ border-radius: 6px;
879
+ animation: shimmer 1.4s ease infinite;
320
880
  }
321
- .toast-success { border-left-color: var(--accent); }
322
- .toast-error { border-left-color: var(--danger); }
881
+ @keyframes shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
323
882
 
324
- @keyframes slideIn {
325
- from { transform: translateX(110%); opacity: 0; }
326
- to { transform: translateX(0); opacity: 1; }
883
+ /* ---------------------------------------------------------------- */
884
+ /* Adaptatif */
885
+ /* ---------------------------------------------------------------- */
886
+ @media (max-width: 1180px) {
887
+ .layout { grid-template-columns: minmax(0, 1fr); gap: 24px; }
327
888
  }
328
- @keyframes fadeOut {
329
- from { opacity: 1; }
330
- to { opacity: 0; }
889
+ @media (max-width: 900px) {
890
+ .shell { grid-template-columns: minmax(0, 1fr); }
891
+ .rail { flex-direction: row; flex-wrap: wrap; align-items: center; border-right: none; border-bottom: 1px solid var(--border); padding: 12px; }
892
+ .rail-label, .rail-divider, .rail-spacer, .danger-zone .zone-label { display: none; }
893
+ .nav-item { width: auto; }
894
+ .nav-item .nav-summary { display: none; }
895
+ .danger-zone { display: flex; gap: 4px; padding: 4px; border: none; background: none; }
896
+ .content { padding: 24px 16px 48px; }
897
+ .appbar { padding: 0 16px; gap: 10px; }
898
+ .brand span, .dirty-note, .switcher .prefix { display: none; }
899
+ .segmented { width: 100%; }
331
900
  }
332
901
  </style>
333
902
  </head>
334
- <body class="min-h-screen p-6 md:p-10">
335
- <div class="app-container">
336
- <!-- Sidebar -->
337
- <aside>
338
- <div class="card p-5 sticky top-10">
339
- <h2 class="section-label mb-4">Configuration</h2>
340
-
341
- <!-- Configuration Selector -->
342
- <div class="flex gap-2 mb-5">
343
- <select id="configSelector" class="field-select flex-1">
344
- <option value="">— Sélectionner —</option>
345
- </select>
346
- <button type="button" id="newConfigBtn" class="btn btn-secondary btn-icon" title="Nouvelle configuration">
347
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
348
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
349
- </svg>
350
- </button>
351
- <button type="button" id="deleteConfigBtn" class="btn btn-danger btn-icon" title="Supprimer la configuration" disabled>
352
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
353
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
354
- </svg>
355
- </button>
903
+ <body>
904
+ <header class="appbar">
905
+ <div class="brand">
906
+ <svg class="ic ic-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>
907
+ <span>GitHub MCP</span>
908
+ </div>
909
+
910
+ <button type="button" class="switcher" id="configSwitcher" title="Changer de configuration">
911
+ <span class="prefix">Config</span>
912
+ <span class="name" id="configName">—</span>
913
+ <svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path></svg>
914
+ </button>
915
+
916
+ <span class="status-pill" id="statusPill">
917
+ <span class="dot"></span>
918
+ <span id="statusLabel">Serveur actif</span>
919
+ </span>
920
+
921
+ <span id="agentBadge" class="chip-static" hidden></span>
922
+
923
+ <div class="appbar-spacer"></div>
924
+
925
+ <span class="dirty-note is-clean" id="dirtyNote">Aucune modification en attente</span>
926
+ <button type="button" class="btn btn-ghost" id="btnDiscard" disabled>Annuler</button>
927
+ <button type="button" class="btn btn-primary" id="btnSave" disabled>Enregistrer</button>
928
+ </header>
929
+
930
+ <div class="shell">
931
+ <nav class="rail">
932
+ <div class="rail-label">Configuration</div>
933
+
934
+ <button type="button" class="nav-item is-active" data-panel="repo">
935
+ <svg class="ic ic-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>
936
+ <span class="nav-text">
937
+ <span class="nav-title">Dépôt &amp; branches</span>
938
+ <span class="nav-summary" id="navSummaryRepo">—</span>
939
+ </span>
940
+ </button>
941
+
942
+ <button type="button" class="nav-item" data-panel="options">
943
+ <svg class="ic ic-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="21" y1="4" x2="14" y2="4"></line><line x1="10" y1="4" x2="3" y2="4"></line><line x1="21" y1="12" x2="12" y2="12"></line><line x1="8" y1="12" x2="3" y2="12"></line><line x1="21" y1="20" x2="16" y2="20"></line><line x1="12" y1="20" x2="3" y2="20"></line><line x1="14" y1="2" x2="14" y2="6"></line><line x1="8" y1="10" x2="8" y2="14"></line><line x1="16" y1="18" x2="16" y2="22"></line></svg>
944
+ <span class="nav-text">
945
+ <span class="nav-title">Options</span>
946
+ <span class="nav-summary" id="navSummaryOptions">—</span>
947
+ </span>
948
+ </button>
949
+
950
+ <button type="button" class="nav-item" data-panel="deploy">
951
+ <svg class="ic ic-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"></rect><rect x="2" y="14" width="20" height="8" rx="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>
952
+ <span class="nav-text">
953
+ <span class="nav-title">Déploiement &amp; test</span>
954
+ <span class="nav-summary" id="navSummaryDeploy">—</span>
955
+ </span>
956
+ </button>
957
+
958
+ <div class="rail-divider"></div>
959
+ <div class="rail-label">Actions</div>
960
+
961
+ <button type="button" class="rail-action" id="btnClone">
962
+ <svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
963
+ <span id="btnCloneLabel">Cloner le dépôt</span>
964
+ </button>
965
+
966
+ <button type="button" class="rail-action" id="btnRestart">
967
+ <svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><polyline points="21 3 21 9 15 9"></polyline></svg>
968
+ <span id="btnRestartLabel">Redémarrer le serveur</span>
969
+ </button>
970
+
971
+ <div class="rail-spacer"></div>
972
+
973
+ <div class="danger-zone">
974
+ <div class="zone-label">
975
+ <svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
976
+ <span>Zone sensible</span>
356
977
  </div>
357
-
358
- <div class="divider pt-4">
359
- <h3 class="section-label mb-3">Actions</h3>
360
-
361
- <div class="space-y-2.5">
362
- <button type="button" onclick="document.getElementById('configForm').requestSubmit()" class="btn btn-primary">
363
- Sauvegarder
364
- </button>
365
-
366
- <button type="button" id="cloneBtn" class="btn btn-secondary">
367
- Cloner le dépôt
368
- </button>
369
-
370
- <button type="button" id="restartBtn" class="btn btn-secondary">
371
- Redémarrer
372
- </button>
373
-
374
- <button type="button" id="deleteWorkingDirBtn" class="btn btn-danger">
375
- Nettoyer le repo
376
- </button>
377
-
378
- <button type="button" id="stopBtn" class="btn btn-danger">
379
- Arrêter le serveur
380
- </button>
381
- </div>
382
- </div>
383
- </div>
384
- </aside>
385
-
386
- <!-- Main Content -->
387
- <main class="card p-7">
388
- <div class="flex items-center gap-3 mb-7">
389
- <h1 class="text-xl font-semibold text-main flex items-center gap-2.5">
390
- <svg class="w-5 h-5 text-dim" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
391
- <path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"></path>
392
- </svg>
393
- GitHub MCP
394
- </h1>
395
- <span id="agentNameBadge" class="badge hidden"></span>
978
+ <button type="button" class="danger-action" id="btnClean">Nettoyer le dossier local</button>
979
+ <button type="button" class="danger-action" id="btnStop">Arrêter le serveur</button>
396
980
  </div>
981
+ </nav>
397
982
 
398
- <form id="configForm" class="space-y-5">
399
- <div class="space-y-4" id="inputs">
400
- <!-- Inputs will be injected here -->
401
- <div class="flex justify-center p-8">
402
- <div class="spinner"></div>
403
- </div>
404
- </div>
405
- </form>
406
-
407
- <div class="mt-8 divider pt-4 text-xs text-muted">
408
- Le serveur MCP doit être redémarré pour appliquer les changements.
409
- </div>
983
+ <main class="content">
984
+ <section id="panel-repo"></section>
985
+ <section id="panel-options" hidden></section>
986
+ <section id="panel-deploy" hidden></section>
410
987
  </main>
411
988
  </div>
412
989
 
413
- <!-- Toast Container -->
414
- <div id="toast-container" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2.5"></div>
990
+ <div id="paletteRoot"></div>
991
+ <div id="modalRoot"></div>
992
+ <div id="toasts"></div>
415
993
 
416
994
  <script>
417
- // Détection automatique du base path pour fonctionner via proxy
995
+ (function () {
996
+ "use strict";
997
+
998
+ /* =============================================================== */
999
+ /* Utilitaires */
1000
+ /* =============================================================== */
1001
+
418
1002
  const pathname = window.location.pathname;
419
1003
  const BASE_PATH = pathname.includes('.')
420
- ? pathname.replace(/\/[^/]*$/, '') // /github-mcp/index.html -> /github-mcp
1004
+ ? pathname.replace(/\/[^/]*$/, '')
421
1005
  : pathname.endsWith('/')
422
- ? pathname.slice(0, -1) // /github-mcp/ -> /github-mcp
423
- : pathname === '/' ? '' : pathname; // /github-mcp -> /github-mcp, / -> ''
1006
+ ? pathname.slice(0, -1)
1007
+ : pathname === '/' ? '' : pathname;
424
1008
 
425
- // Helper pour construire les URLs d'API
426
1009
  const api = (endpoint) => `${BASE_PATH}/${endpoint}`.replace(/\/+/g, '/');
427
1010
 
428
- const form = document.getElementById('configForm');
429
- const inputsContainer = document.getElementById('inputs');
430
- const restartBtn = document.getElementById('restartBtn');
431
- const stopBtn = document.getElementById('stopBtn');
432
- const deleteWorkingDirBtn = document.getElementById('deleteWorkingDirBtn');
433
- const cloneBtn = document.getElementById('cloneBtn');
434
-
435
- const fields = [
436
- { id: 'TARGET_REPO', label: 'Target Repository', type: 'select', placeholder: 'owner/repo' },
437
- { id: 'BASE_BRANCH', label: 'Base Branch', type: 'select', placeholder: 'main' },
438
- { id: 'TARGET_BRANCH', label: 'Target Branch', type: 'select', placeholder: 'feature-branch' },
439
- { id: 'LOCAL_DEPLOY', label: 'Déployer en local (même serveur, sans SSH)', type: 'slider' },
440
- { id: 'SSH_HOST', label: 'Serveur SSH (SSH_HOST)', type: 'text', placeholder: 'srv.example.com' },
441
- { id: 'SSH_USER', label: 'Utilisateur SSH (SSH_USER)', type: 'text', placeholder: 'root' },
442
- { id: 'SSH_PORT', label: 'Port SSH (SSH_PORT)', type: 'text', placeholder: '22' },
443
- { id: 'SSH_KEY_PATH', label: 'Clé privée SSH (SSH_KEY_PATH)', type: 'text', placeholder: 'C:\\Users\\moi\\.ssh\\id_ed25519' },
444
- { id: 'SSH_REMOTE_PATH', label: 'Chemin du projet (distant ou local)', type: 'text', placeholder: '/var/www/mon-projet' },
445
- { id: 'TEST_COMMAND', label: 'Commande de test (TEST_COMMAND)', type: 'text', placeholder: 'npm test' },
446
- { id: 'READ_ONLY', label: 'Mode Lecture Seule', type: 'slider' },
447
- { id: 'INCLUDE_PUBLIC', label: 'Inclure les dépôts publics', type: 'slider' },
448
- { id: 'INCLUDE_NOT_OWNED', label: 'Inclure les dépôts dont je ne suis pas propriétaire', type: 'slider' }
449
- ];
450
-
451
- // Index des champs par id (pour le rendu groupé en sections)
452
- const fieldsById = Object.fromEntries(fields.map(f => [f.id, f]));
1011
+ const $ = (id) => document.getElementById(id);
1012
+
1013
+ const esc = (value) => String(value == null ? '' : value)
1014
+ .replace(/&/g, '&amp;')
1015
+ .replace(/</g, '&lt;')
1016
+ .replace(/>/g, '&gt;')
1017
+ .replace(/"/g, '&quot;');
1018
+
1019
+ const ICONS = {
1020
+ branch: '<line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path>',
1021
+ book: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>',
1022
+ chevrons: '<path d="m7 15 5 5 5-5"></path><path d="m7 9 5-5 5 5"></path>',
1023
+ arrowRight: '<line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline>',
1024
+ search: '<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>',
1025
+ plus: '<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>',
1026
+ check: '<circle cx="12" cy="12" r="10"></circle><path d="m9 12 2 2 4-4"></path>',
1027
+ cross: '<circle cx="12" cy="12" r="10"></circle><path d="m15 9-6 6"></path><path d="m9 9 6 6"></path>',
1028
+ alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line>',
1029
+ info: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line>',
1030
+ refresh: '<path d="M21 12a9 9 0 1 1-2.64-6.36"></path><polyline points="21 3 21 9 15 9"></polyline>',
1031
+ download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line>',
1032
+ server: '<rect x="2" y="2" width="20" height="8" rx="2"></rect><rect x="2" y="14" width="20" height="8" rx="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line>',
1033
+ drive: '<line x1="22" y1="12" x2="2" y2="12"></line><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path><line x1="6" y1="16" x2="6.01" y2="16"></line>',
1034
+ plug: '<path d="M12 22v-5"></path><path d="M9 8V2"></path><path d="M15 8V2"></path><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"></path>',
1035
+ lock: '<rect x="3" y="11" width="18" height="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path>',
1036
+ globe: '<circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>',
1037
+ folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>',
1038
+ sliders: '<line x1="21" y1="4" x2="14" y2="4"></line><line x1="10" y1="4" x2="3" y2="4"></line><line x1="21" y1="12" x2="12" y2="12"></line><line x1="8" y1="12" x2="3" y2="12"></line><line x1="21" y1="20" x2="16" y2="20"></line><line x1="12" y1="20" x2="3" y2="20"></line><line x1="14" y1="2" x2="14" y2="6"></line><line x1="8" y1="10" x2="8" y2="14"></line><line x1="16" y1="18" x2="16" y2="22"></line>',
1039
+ x: '<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>'
1040
+ };
453
1041
 
454
- // Sections du formulaire. Le coeur (dépôt/branches) est toujours déplié ;
455
- // les autres sont repliables, le paramétrage serveur étant fait une seule fois.
456
- const sections = [
457
- {
458
- id: 'core',
459
- title: 'Dépôt & branches',
460
- collapsible: false,
461
- fields: ['TARGET_REPO', 'BASE_BRANCH', 'TARGET_BRANCH']
462
- },
463
- {
464
- id: 'options',
465
- title: 'Options',
466
- collapsible: true,
467
- fields: ['READ_ONLY', 'INCLUDE_PUBLIC', 'INCLUDE_NOT_OWNED']
468
- },
469
- {
470
- id: 'deploy',
471
- title: 'Déploiement & test',
472
- collapsible: true,
473
- fields: ['LOCAL_DEPLOY', 'SSH_HOST', 'SSH_USER', 'SSH_PORT', 'SSH_KEY_PATH', 'SSH_REMOTE_PATH', 'TEST_COMMAND']
1042
+ const icon = (name, cls) => `<svg class="ic ${cls || ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
1043
+
1044
+ function timeAgo(value) {
1045
+ if (!value) return '';
1046
+ const then = new Date(value).getTime();
1047
+ if (Number.isNaN(then)) return '';
1048
+ const seconds = Math.max(1, Math.round((Date.now() - then) / 1000));
1049
+ const steps = [
1050
+ [60, 'à l\u2019instant', 1],
1051
+ [3600, 'min', 60],
1052
+ [86400, 'h', 3600],
1053
+ [2592000, 'j', 86400],
1054
+ [31536000, 'mois', 2592000]
1055
+ ];
1056
+ if (seconds < 60) return 'à l\u2019instant';
1057
+ for (const [limit, unit, divisor] of steps) {
1058
+ if (seconds < limit) return `il y a ${Math.round(seconds / divisor)} ${unit}`;
474
1059
  }
475
- ];
476
-
477
- // État déplié/replié des sections repliables (false = replié par défaut)
478
- const sectionState = { options: false, deploy: false };
1060
+ return `il y a ${Math.round(seconds / 31536000)} an(s)`;
1061
+ }
479
1062
 
480
- // Champs propres à la connexion SSH : masqués quand LOCAL_DEPLOY est actif
481
- const sshOnlyFields = ['SSH_HOST', 'SSH_USER', 'SSH_PORT', 'SSH_KEY_PATH'];
1063
+ const isTrue = (value) => value === true || value === 'true';
1064
+
1065
+ /* =============================================================== */
1066
+ /* État */
1067
+ /* =============================================================== */
1068
+
1069
+ const FIELDS = [
1070
+ { id: 'TARGET_REPO', label: 'Dépôt', type: 'text' },
1071
+ { id: 'BASE_BRANCH', label: 'Branche de base', type: 'text' },
1072
+ { id: 'TARGET_BRANCH', label: 'Branche de travail', type: 'text' },
1073
+ { id: 'LOCAL_DEPLOY', label: 'Cible de déploiement', type: 'bool' },
1074
+ { id: 'SSH_HOST', label: 'Hôte SSH', type: 'text' },
1075
+ { id: 'SSH_USER', label: 'Utilisateur SSH', type: 'text' },
1076
+ { id: 'SSH_PORT', label: 'Port SSH', type: 'text' },
1077
+ { id: 'SSH_KEY_PATH', label: 'Clé privée SSH', type: 'text' },
1078
+ { id: 'SSH_REMOTE_PATH', label: 'Chemin du projet', type: 'text' },
1079
+ { id: 'TEST_COMMAND', label: 'Commande de test', type: 'text' },
1080
+ { id: 'READ_ONLY', label: 'Mode lecture seule', type: 'bool' },
1081
+ { id: 'INCLUDE_PUBLIC', label: 'Dépôts publics', type: 'bool' },
1082
+ { id: 'INCLUDE_NOT_OWNED', label: 'Dépôts d\u2019autres propriétaires', type: 'bool' }
1083
+ ];
482
1084
 
483
- // État de l'application
484
- let currentConfig = {};
485
- let currentConfigName = '';
486
- let configNames = [];
487
- const selectState = {
1085
+ const state = {
1086
+ panel: 'repo',
1087
+ config: {},
1088
+ saved: {},
1089
+ extras: {},
1090
+ configNames: [],
1091
+ currentConfigName: '',
488
1092
  repos: [],
489
- branches: {},
1093
+ repoMeta: {},
490
1094
  loadingRepos: false,
491
- loadingBranches: {}
1095
+ branches: {},
1096
+ loadingBranches: {},
1097
+ sshTest: null,
1098
+ activity: [],
1099
+ serverOk: true
492
1100
  };
493
1101
 
494
- let searchTimeout;
495
- let activeIndex = -1;
496
-
497
- // Configuration management
498
- const configSelector = document.getElementById('configSelector');
499
- const newConfigBtn = document.getElementById('newConfigBtn');
500
- const deleteConfigBtn = document.getElementById('deleteConfigBtn');
1102
+ /* =============================================================== */
1103
+ /* Toasts */
1104
+ /* =============================================================== */
1105
+
1106
+ function toast(options) {
1107
+ const { type = 'info', title, detail, actionLabel, onAction, sticky } = options;
1108
+ const iconName = type === 'error' ? 'cross' : type === 'success' ? 'check' : 'info';
1109
+ const cls = type === 'error' ? 'is-error' : type === 'success' ? 'is-ok' : 'is-info';
1110
+
1111
+ const node = document.createElement('div');
1112
+ node.className = `toast ${cls}`;
1113
+ node.innerHTML = `
1114
+ <span class="toast-icon">${icon(iconName, 'ic-lg')}</span>
1115
+ <span class="toast-text">
1116
+ <span class="toast-title">${esc(title)}</span>
1117
+ ${detail ? `<span class="toast-detail">${esc(detail)}</span>` : ''}
1118
+ </span>
1119
+ ${actionLabel ? `<button type="button" class="toast-action">${esc(actionLabel)}</button>` : ''}
1120
+ <button type="button" class="toast-close" aria-label="Fermer">${icon('x')}</button>
1121
+ `;
501
1122
 
502
- async function fetchConfigs() {
503
- try {
504
- const response = await fetch(api('api/configs'));
505
- if (response.ok) {
506
- const data = await response.json();
507
- configNames = data.names || [];
508
- currentConfigName = data.current || '';
509
- renderConfigSelector();
510
- }
511
- } catch (error) {
512
- console.warn('Error fetching configs:', error);
1123
+ const dismiss = () => node.remove();
1124
+ node.querySelector('.toast-close').addEventListener('click', dismiss);
1125
+ const actionBtn = node.querySelector('.toast-action');
1126
+ if (actionBtn) {
1127
+ actionBtn.addEventListener('click', () => { dismiss(); if (onAction) onAction(); });
513
1128
  }
1129
+
1130
+ $('toasts').appendChild(node);
1131
+ if (!sticky && type !== 'error') setTimeout(dismiss, 6000);
1132
+ }
1133
+
1134
+ function logActivity(tone, label) {
1135
+ state.activity.unshift({ tone, label, at: Date.now() });
1136
+ if (state.activity.length > 20) state.activity.length = 20;
1137
+ if (state.panel === 'deploy') renderActivity();
514
1138
  }
515
1139
 
516
- function renderConfigSelector() {
517
- configSelector.innerHTML = '<option value="">— Sélectionner —</option>' +
518
- configNames.map(name =>
519
- `<option value="${name}" ${name === currentConfigName ? 'selected' : ''}>${name}</option>`
520
- ).join('');
1140
+ /* =============================================================== */
1141
+ /* Modale générique */
1142
+ /* =============================================================== */
1143
+
1144
+ function openModal(options) {
1145
+ const {
1146
+ title, body, pathText, input, confirmWord,
1147
+ confirmLabel = 'Confirmer', cancelLabel = 'Annuler', variant = 'default'
1148
+ } = options;
1149
+
1150
+ return new Promise((resolve) => {
1151
+ const root = $('modalRoot');
1152
+ root.innerHTML = `
1153
+ <div class="overlay overlay-center" data-overlay>
1154
+ <div class="modal" role="dialog" aria-modal="true">
1155
+ <div class="modal-head">
1156
+ <span class="modal-icon ${variant === 'danger' ? 'is-danger' : ''}">
1157
+ ${icon(variant === 'danger' ? 'alert' : 'info', 'ic-xl')}
1158
+ </span>
1159
+ <span class="modal-title">${esc(title)}</span>
1160
+ </div>
1161
+ ${body ? `<p class="modal-body">${esc(body)}</p>` : ''}
1162
+ ${pathText ? `<div class="modal-path">${esc(pathText)}</div>` : ''}
1163
+ ${input ? `
1164
+ <div class="modal-field">
1165
+ <label for="modalInput">${esc(input.label)}</label>
1166
+ <input class="input ${input.mono ? 'is-mono' : ''}" id="modalInput"
1167
+ placeholder="${esc(input.placeholder || '')}"
1168
+ value="${esc(input.value || '')}" autocomplete="off">
1169
+ </div>` : ''}
1170
+ ${confirmWord ? `
1171
+ <div class="modal-field">
1172
+ <label for="modalConfirmWord">Tapez «&nbsp;${esc(confirmWord)}&nbsp;» pour confirmer</label>
1173
+ <input class="input is-mono" id="modalConfirmWord"
1174
+ placeholder="${esc(confirmWord)}" autocomplete="off">
1175
+ </div>` : ''}
1176
+ <div class="modal-actions">
1177
+ <button type="button" class="btn btn-ghost btn-lg" id="modalCancel">${esc(cancelLabel)}</button>
1178
+ <button type="button" class="btn btn-lg ${variant === 'danger' ? 'btn-danger' : 'btn-primary'}" id="modalConfirm">${esc(confirmLabel)}</button>
1179
+ </div>
1180
+ </div>
1181
+ </div>
1182
+ `;
1183
+
1184
+ const confirmBtn = $('modalConfirm');
1185
+ const wordInput = $('modalConfirmWord');
1186
+ const valueInput = $('modalInput');
521
1187
 
522
- deleteConfigBtn.disabled = !currentConfigName || configNames.length <= 1;
1188
+ const validate = () => {
1189
+ let ok = true;
1190
+ if (confirmWord) ok = wordInput.value.trim() === confirmWord;
1191
+ if (input && input.required !== false) ok = ok && valueInput.value.trim().length > 0;
1192
+ confirmBtn.disabled = !ok;
1193
+ };
1194
+
1195
+ const close = (result) => {
1196
+ document.removeEventListener('keydown', onKey);
1197
+ root.innerHTML = '';
1198
+ resolve(result);
1199
+ };
1200
+
1201
+ const onKey = (event) => {
1202
+ if (event.key === 'Escape') close(null);
1203
+ if (event.key === 'Enter' && !confirmBtn.disabled) {
1204
+ event.preventDefault();
1205
+ close(valueInput ? valueInput.value.trim() : true);
1206
+ }
1207
+ };
1208
+
1209
+ if (wordInput) wordInput.addEventListener('input', validate);
1210
+ if (valueInput) valueInput.addEventListener('input', validate);
1211
+ validate();
1212
+
1213
+ $('modalCancel').addEventListener('click', () => close(null));
1214
+ confirmBtn.addEventListener('click', () => close(valueInput ? valueInput.value.trim() : true));
1215
+ root.querySelector('[data-overlay]').addEventListener('mousedown', (event) => {
1216
+ if (event.target.hasAttribute('data-overlay')) close(null);
1217
+ });
1218
+ document.addEventListener('keydown', onKey);
1219
+
1220
+ setTimeout(() => (valueInput || wordInput || confirmBtn).focus(), 0);
1221
+ });
1222
+ }
1223
+
1224
+ /* =============================================================== */
1225
+ /* Palette */
1226
+ /* =============================================================== */
1227
+
1228
+ let palette = null;
1229
+
1230
+ function openPalette(options) {
1231
+ closePalette();
1232
+ palette = {
1233
+ placeholder: options.placeholder || 'Rechercher…',
1234
+ groupLabel: options.groupLabel || '',
1235
+ footerHint: options.footerHint || '',
1236
+ emptyLabel: options.emptyLabel || 'Aucun résultat',
1237
+ filters: options.filters || null,
1238
+ onPick: options.onPick,
1239
+ createLabel: options.createLabel || null,
1240
+ validateCreate: options.validateCreate || null,
1241
+ source: options.source,
1242
+ remote: !!options.remote,
1243
+ items: [],
1244
+ active: 0,
1245
+ query: '',
1246
+ loading: false,
1247
+ timer: null
1248
+ };
1249
+
1250
+ const root = $('paletteRoot');
1251
+ root.innerHTML = `
1252
+ <div class="overlay overlay-top" data-overlay>
1253
+ <div class="palette" role="dialog" aria-modal="true">
1254
+ <div class="palette-search">
1255
+ ${icon('search', 'ic-xl')}
1256
+ <input id="paletteInput" placeholder="${esc(palette.placeholder)}" autocomplete="off" spellcheck="false">
1257
+ <span class="spinner" id="paletteSpinner" hidden></span>
1258
+ <span class="kbd">esc</span>
1259
+ </div>
1260
+ <div class="palette-filters" id="paletteFilters" ${palette.filters ? '' : 'hidden'}></div>
1261
+ <div class="palette-list" id="paletteList"></div>
1262
+ <div class="palette-footer">
1263
+ <span>↑↓ naviguer &nbsp;&nbsp; ↵ sélectionner &nbsp;&nbsp; esc fermer</span>
1264
+ <span class="right">${esc(palette.footerHint)}</span>
1265
+ </div>
1266
+ </div>
1267
+ </div>
1268
+ `;
1269
+
1270
+ const input = $('paletteInput');
1271
+ input.addEventListener('input', onPaletteInput);
1272
+ input.addEventListener('keydown', onPaletteKey);
1273
+ root.querySelector('[data-overlay]').addEventListener('mousedown', (event) => {
1274
+ if (event.target.hasAttribute('data-overlay')) closePalette();
1275
+ });
1276
+
1277
+ renderPaletteFilters();
1278
+ refreshPalette('');
1279
+ setTimeout(() => input.focus(), 0);
1280
+ }
1281
+
1282
+ function closePalette() {
1283
+ if (palette && palette.timer) clearTimeout(palette.timer);
1284
+ palette = null;
1285
+ $('paletteRoot').innerHTML = '';
1286
+ }
1287
+
1288
+ function renderPaletteFilters() {
1289
+ if (!palette || !palette.filters) return;
1290
+ const container = $('paletteFilters');
1291
+ container.innerHTML = palette.filters.map((filter, index) =>
1292
+ `<button type="button" class="chip ${filter.on ? 'is-on' : ''}" data-filter="${index}">${esc(filter.label)}</button>`
1293
+ ).join('') + `<span class="palette-count" id="paletteCount"></span>`;
1294
+
1295
+ container.querySelectorAll('[data-filter]').forEach((chip) => {
1296
+ chip.addEventListener('click', async () => {
1297
+ const filter = palette.filters[Number(chip.dataset.filter)];
1298
+ filter.on = !filter.on;
1299
+ renderPaletteFilters();
1300
+ await filter.onToggle(filter.on);
1301
+ refreshPalette(palette.query, true);
1302
+ });
1303
+ });
1304
+ }
1305
+
1306
+ function onPaletteInput(event) {
1307
+ if (!palette) return;
1308
+ palette.query = event.target.value;
1309
+ if (palette.remote) {
1310
+ if (palette.timer) clearTimeout(palette.timer);
1311
+ palette.timer = setTimeout(() => refreshPalette(palette.query, true), 400);
1312
+ renderPaletteList();
1313
+ } else {
1314
+ refreshPalette(palette.query);
1315
+ }
1316
+ }
1317
+
1318
+ function onPaletteKey(event) {
1319
+ if (!palette) return;
1320
+ const count = palette.rendered ? palette.rendered.length : 0;
1321
+ if (event.key === 'Escape') { closePalette(); return; }
1322
+ if (!count) return;
1323
+
1324
+ if (event.key === 'ArrowDown') {
1325
+ event.preventDefault();
1326
+ palette.active = Math.min(palette.active + 1, count - 1);
1327
+ renderPaletteList(true);
1328
+ } else if (event.key === 'ArrowUp') {
1329
+ event.preventDefault();
1330
+ palette.active = Math.max(palette.active - 1, 0);
1331
+ renderPaletteList(true);
1332
+ } else if (event.key === 'Enter') {
1333
+ event.preventDefault();
1334
+ pickPaletteItem(palette.active);
1335
+ }
523
1336
  }
524
1337
 
525
- async function activateConfig(name) {
526
- if (!name) return;
527
- try {
528
- const response = await fetch(api(`api/configs/${encodeURIComponent(name)}/activate`), {
529
- method: 'POST'
530
- });
531
- if (response.ok) {
532
- currentConfigName = name;
533
- // Reset branches state to force reload for new config
534
- selectState.branches = {};
535
- showStatus(`Configuration "${name}" activée`, 'success');
536
- await fetchConfig();
537
- } else {
538
- throw new Error('Erreur lors de l\'activation');
1338
+ async function refreshPalette(query, force) {
1339
+ if (!palette) return;
1340
+ // La palette peut être fermée pendant l'attente réseau : on garde
1341
+ // la référence ouverte au départ et on abandonne si elle a changé.
1342
+ const owner = palette;
1343
+
1344
+ if (owner.remote) {
1345
+ owner.loading = true;
1346
+ renderPaletteList();
1347
+ const spinner = $('paletteSpinner');
1348
+ if (spinner) spinner.hidden = false;
1349
+
1350
+ let items = [];
1351
+ let failure = null;
1352
+ try {
1353
+ items = await owner.source(query);
1354
+ } catch (error) {
1355
+ failure = error;
539
1356
  }
540
- } catch (error) {
541
- showStatus(error.message, 'error');
1357
+
1358
+ if (palette !== owner) return;
1359
+ owner.loading = false;
1360
+ owner.items = items;
1361
+ const spinnerAfter = $('paletteSpinner');
1362
+ if (spinnerAfter) spinnerAfter.hidden = true;
1363
+ if (failure) toast({ type: 'error', title: 'Recherche impossible', detail: failure.message });
1364
+ } else if (force || !owner.items.length) {
1365
+ const items = await owner.source(query);
1366
+ if (palette !== owner) return;
1367
+ owner.items = items;
542
1368
  }
1369
+
1370
+ owner.active = 0;
1371
+ renderPaletteList();
543
1372
  }
544
1373
 
545
- async function createNewConfig() {
546
- const name = prompt('Nom de la nouvelle configuration:');
547
- if (!name || !name.trim()) return;
1374
+ function renderPaletteList(scrollOnly) {
1375
+ if (!palette) return;
1376
+ const list = $('paletteList');
1377
+ if (!list) return;
548
1378
 
549
- const trimmedName = name.trim();
550
- if (configNames.includes(trimmedName)) {
551
- showStatus('Une configuration avec ce nom existe déjà', 'error');
552
- return;
1379
+ const query = palette.query.trim();
1380
+ let items = palette.items;
1381
+ if (!palette.remote && query) {
1382
+ const needle = query.toLowerCase();
1383
+ items = items.filter((item) => item.title.toLowerCase().includes(needle));
553
1384
  }
554
1385
 
555
- const newConfig = {
556
- TARGET_REPO: '',
557
- TARGET_BRANCH: '',
558
- BASE_BRANCH: '',
559
- LOCAL_DEPLOY: false,
560
- SSH_HOST: '',
561
- SSH_USER: '',
562
- SSH_PORT: '',
563
- SSH_KEY_PATH: '',
564
- SSH_REMOTE_PATH: '',
565
- TEST_COMMAND: '',
566
- READ_ONLY: currentConfig.READ_ONLY || false,
567
- INCLUDE_PUBLIC: currentConfig.INCLUDE_PUBLIC || false,
568
- INCLUDE_NOT_OWNED: currentConfig.INCLUDE_NOT_OWNED || false
569
- };
570
-
571
- try {
572
- const response = await fetch(api('api/configs'), {
573
- method: 'POST',
574
- headers: { 'Content-Type': 'application/json' },
575
- body: JSON.stringify({ name: trimmedName, config: newConfig })
1386
+ const rendered = items.slice();
1387
+ const exact = items.some((item) => item.title === query);
1388
+ if (palette.createLabel && query && !exact && (!palette.validateCreate || palette.validateCreate(query))) {
1389
+ rendered.unshift({
1390
+ id: query,
1391
+ title: palette.createLabel.replace('%s', query),
1392
+ subtitle: 'Sera créée au premier commit',
1393
+ iconName: 'plus',
1394
+ isCreate: true,
1395
+ value: query
576
1396
  });
1397
+ }
1398
+ palette.rendered = rendered;
577
1399
 
578
- if (response.ok) {
579
- showStatus(`Configuration "${trimmedName}" créée (basée sur la configuration actuelle)`, 'success');
580
- await fetchConfigs();
581
- await activateConfig(trimmedName);
582
- } else {
583
- throw new Error('Erreur lors de la création');
584
- }
585
- } catch (error) {
586
- showStatus(error.message, 'error');
1400
+ const count = $('paletteCount');
1401
+ if (count) count.textContent = palette.loading ? 'recherche…' : `${items.length} résultat${items.length > 1 ? 's' : ''}`;
1402
+
1403
+ if (scrollOnly && list.children.length) {
1404
+ list.querySelectorAll('.palette-item').forEach((node, index) => {
1405
+ node.classList.toggle('is-active', index === palette.active);
1406
+ if (index === palette.active) node.scrollIntoView({ block: 'nearest' });
1407
+ });
1408
+ return;
587
1409
  }
588
- }
589
1410
 
590
- async function deleteCurrentConfig() {
591
- if (!currentConfigName) return;
592
- if (configNames.length <= 1) {
593
- showStatus('Impossible de supprimer la dernière configuration', 'error');
1411
+ if (palette.loading && !rendered.length) {
1412
+ list.innerHTML = `
1413
+ <div style="padding:12px">
1414
+ <div class="skeleton" style="height:52px;margin-bottom:8px"></div>
1415
+ <div class="skeleton" style="height:52px;margin-bottom:8px"></div>
1416
+ <div class="skeleton" style="height:52px"></div>
1417
+ </div>`;
594
1418
  return;
595
1419
  }
596
1420
 
597
- if (!confirm(`Êtes-vous sûr de vouloir supprimer la configuration "${currentConfigName}" ?`)) {
1421
+ if (!rendered.length) {
1422
+ list.innerHTML = `<div class="palette-empty">${esc(palette.emptyLabel)}</div>`;
598
1423
  return;
599
1424
  }
600
1425
 
601
- try {
602
- const response = await fetch(api(`api/configs/${encodeURIComponent(currentConfigName)}`), {
603
- method: 'DELETE'
604
- });
1426
+ list.innerHTML = (palette.groupLabel ? `<div class="palette-group-label">${esc(palette.groupLabel)}</div>` : '')
1427
+ + rendered.map((item, index) => `
1428
+ <button type="button" class="palette-item ${index === palette.active ? 'is-active' : ''} ${item.current ? 'is-current' : ''} ${item.isCreate ? 'is-create' : ''}" data-index="${index}">
1429
+ <span class="item-icon">${icon(item.iconName || 'book', 'ic-lg')}</span>
1430
+ <span class="item-text">
1431
+ <span class="item-title">${esc(item.title)}</span>
1432
+ ${item.subtitle ? `<span class="item-subtitle">${esc(item.subtitle)}</span>` : ''}
1433
+ </span>
1434
+ ${item.badge ? `<span class="item-badge">${esc(item.badge)}</span>` : ''}
1435
+ </button>
1436
+ `).join('');
605
1437
 
606
- if (response.ok) {
607
- showStatus(`Configuration "${currentConfigName}" supprimée`, 'success');
608
- await fetchConfigs();
609
- // Activate first remaining config
610
- if (configNames.length > 0) {
611
- await activateConfig(configNames[0]);
612
- }
613
- } else {
614
- throw new Error('Erreur lors de la suppression');
615
- }
616
- } catch (error) {
617
- showStatus(error.message, 'error');
618
- }
1438
+ list.querySelectorAll('.palette-item').forEach((node) => {
1439
+ node.addEventListener('click', () => pickPaletteItem(Number(node.dataset.index)));
1440
+ });
1441
+ }
1442
+
1443
+ function pickPaletteItem(index) {
1444
+ if (!palette || !palette.rendered) return;
1445
+ const item = palette.rendered[index];
1446
+ if (!item) return;
1447
+ const handler = palette.onPick;
1448
+ closePalette();
1449
+ handler(item.value != null ? item.value : item.id, item);
619
1450
  }
620
1451
 
621
- configSelector.addEventListener('change', (e) => {
622
- if (e.target.value) {
623
- activateConfig(e.target.value);
1452
+ /* =============================================================== */
1453
+ /* Réseau */
1454
+ /* =============================================================== */
1455
+
1456
+ async function request(endpoint, options) {
1457
+ const response = await fetch(api(endpoint), options);
1458
+ if (!response.ok) {
1459
+ const data = await response.json().catch(() => ({}));
1460
+ throw new Error(data.error || `Erreur ${response.status}`);
624
1461
  }
625
- });
1462
+ const text = await response.text();
1463
+ return text ? JSON.parse(text) : null;
1464
+ }
1465
+
1466
+ async function loadConfigs() {
1467
+ const data = await request('api/configs');
1468
+ state.configNames = data.names || [];
1469
+ state.currentConfigName = data.current || '';
1470
+ }
626
1471
 
627
- newConfigBtn.addEventListener('click', createNewConfig);
628
- deleteConfigBtn.addEventListener('click', deleteCurrentConfig);
1472
+ async function loadConfig() {
1473
+ const data = await request('api/config');
1474
+ const config = {};
1475
+ FIELDS.forEach((field) => {
1476
+ config[field.id] = field.type === 'bool' ? isTrue(data[field.id]) : (data[field.id] || '');
1477
+ });
1478
+ state.config = config;
1479
+ state.saved = Object.assign({}, config);
1480
+ state.extras = {
1481
+ AGENT_NAME: data.AGENT_NAME || '',
1482
+ CLONE_DIR: data.CLONE_DIR || '',
1483
+ REPO_PATH: data.REPO_PATH || ''
1484
+ };
1485
+ }
629
1486
 
630
- // Fetch repositories
631
- async function fetchRepositories(searchTerm = '') {
632
- const loadingIndicator = document.getElementById('REPO_LOADING_INDICATOR');
633
- if (loadingIndicator) loadingIndicator.classList.remove('hidden');
1487
+ function normalizeRepo(entry) {
1488
+ if (typeof entry === 'string') return { full_name: entry };
1489
+ return entry || {};
1490
+ }
634
1491
 
1492
+ async function fetchRepositories(search) {
1493
+ state.loadingRepos = true;
635
1494
  try {
636
- const url = api(`api/repositories${searchTerm ? `?search=${encodeURIComponent(searchTerm)}` : ''}`);
637
- const response = await fetch(url);
638
- if (response.ok) {
639
- selectState.repos = await response.json();
640
- renderRepoResults();
641
- }
642
- } catch (error) {
643
- console.warn('Error fetching repositories:', error);
1495
+ const query = search ? `?search=${encodeURIComponent(search)}` : '';
1496
+ const data = await request(`api/repositories${query}`);
1497
+ const repos = (data || []).map(normalizeRepo).filter((repo) => repo.full_name);
1498
+ repos.forEach((repo) => { state.repoMeta[repo.full_name] = repo; });
1499
+ state.repos = repos;
1500
+ return repos;
644
1501
  } finally {
645
- if (loadingIndicator) loadingIndicator.classList.add('hidden');
1502
+ state.loadingRepos = false;
646
1503
  }
647
1504
  }
648
1505
 
649
- // Affiche les résultats chargés dans le DOM
650
- function renderRepoResults() {
651
- const resultsContainer = document.getElementById('REPO_RESULTS');
652
- if (!resultsContainer) return;
1506
+ async function fetchBranches(repo) {
1507
+ if (!repo) return [];
1508
+ state.loadingBranches[repo] = true;
1509
+ try {
1510
+ const data = await request(`api/branches?repo=${encodeURIComponent(repo)}`);
1511
+ state.branches[repo] = data || [];
1512
+ return state.branches[repo];
1513
+ } catch (error) {
1514
+ state.branches[repo] = [];
1515
+ toast({ type: 'error', title: 'Branches indisponibles', detail: error.message });
1516
+ return [];
1517
+ } finally {
1518
+ state.loadingBranches[repo] = false;
1519
+ }
1520
+ }
653
1521
 
654
- if (selectState.repos.length > 0) {
655
- const currentRepo = document.getElementById('TARGET_REPO')?.value;
656
- resultsContainer.innerHTML = selectState.repos.map((repo, index) => `
657
- <div class="search-result-item ${repo === currentRepo ? 'selected' : ''}" data-index="${index}" onclick="selectRepo('${repo}')">
658
- ${repo}
659
- ${repo === currentRepo ? '<span class="float-right">✓</span>' : ''}
660
- </div>
661
- `).join('');
662
- resultsContainer.classList.remove('hidden');
663
- activeIndex = -1;
664
- } else {
665
- resultsContainer.innerHTML = '<div class="search-result-item">Aucun résultat</div>';
666
- resultsContainer.classList.remove('hidden');
1522
+ async function checkHealth() {
1523
+ try {
1524
+ await request('proxy/health');
1525
+ state.serverOk = true;
1526
+ } catch (_) {
1527
+ state.serverOk = false;
667
1528
  }
1529
+ renderStatus();
668
1530
  }
669
1531
 
670
- // Sélectionner un repo depuis la liste custom
671
- window.selectRepo = async function(repo) {
672
- const searchInput = document.getElementById('REPO_SEARCH');
673
- const hiddenInput = document.getElementById('TARGET_REPO');
674
- const resultsContainer = document.getElementById('REPO_RESULTS');
1532
+ /* =============================================================== */
1533
+ /* État « modifié » */
1534
+ /* =============================================================== */
675
1535
 
676
- if (searchInput) searchInput.value = repo;
677
- if (hiddenInput) {
678
- hiddenInput.value = repo;
679
- currentConfig.TARGET_REPO = repo;
680
- }
681
- if (resultsContainer) resultsContainer.classList.add('hidden');
1536
+ function changedFields() {
1537
+ return FIELDS.filter((field) => {
1538
+ const current = state.config[field.id];
1539
+ const saved = state.saved[field.id];
1540
+ if (field.type === 'bool') return !!current !== !!saved;
1541
+ return (current || '') !== (saved || '');
1542
+ });
1543
+ }
682
1544
 
683
- // Réinitialiser les branches lors du changement de repository
684
- currentConfig.BASE_BRANCH = '';
685
- currentConfig.TARGET_BRANCH = '';
1545
+ function setField(id, value) {
1546
+ state.config[id] = value;
1547
+ refreshDerived();
1548
+ }
686
1549
 
687
- // Charger les branches pour ce repo (renderInputs is called inside fetchBranches)
688
- await fetchBranches(repo);
689
- };
1550
+ /* =============================================================== */
1551
+ /* Rendu — parties persistantes */
1552
+ /* =============================================================== */
690
1553
 
691
- // Fetch branches for a repository
692
- async function fetchBranches(repo, preserveConfig = false) {
693
- if (!repo) return [];
1554
+ function renderStatus() {
1555
+ const pill = $('statusPill');
1556
+ pill.classList.toggle('is-down', !state.serverOk);
1557
+ $('statusLabel').textContent = state.serverOk ? 'Serveur actif' : 'Serveur injoignable';
1558
+ }
694
1559
 
695
- selectState.loadingBranches[repo] = true;
696
- // Use renderInputs directly to avoid overwriting currentConfig from DOM
697
- renderInputs(currentConfig);
1560
+ function renderChrome() {
1561
+ $('configName').textContent = state.currentConfigName || 'Aucune configuration';
698
1562
 
699
- try {
700
- const url = api(`api/branches?repo=${encodeURIComponent(repo)}`);
701
- const response = await fetch(url);
702
- if (response.ok) {
703
- selectState.branches[repo] = await response.json();
704
- }
705
- } catch (error) {
706
- console.error('[fetchBranches] Fetch error:', error);
707
- selectState.branches[repo] = [];
708
- } finally {
709
- selectState.loadingBranches[repo] = false;
710
- // Use renderInputs directly to avoid overwriting currentConfig from DOM
711
- renderInputs(currentConfig);
1563
+ const badge = $('agentBadge');
1564
+ if (state.extras.AGENT_NAME) {
1565
+ badge.textContent = state.extras.AGENT_NAME;
1566
+ badge.hidden = false;
1567
+ } else {
1568
+ badge.hidden = true;
1569
+ }
1570
+ }
1571
+
1572
+ function refreshDerived() {
1573
+ const changed = changedFields();
1574
+ const note = $('dirtyNote');
1575
+ if (changed.length) {
1576
+ note.textContent = `${changed.length} modification${changed.length > 1 ? 's' : ''} non enregistrée${changed.length > 1 ? 's' : ''}`;
1577
+ note.classList.remove('is-clean');
1578
+ note.title = changed.map((field) => field.label).join(', ');
1579
+ } else {
1580
+ note.textContent = 'Aucune modification en attente';
1581
+ note.classList.add('is-clean');
1582
+ note.title = '';
712
1583
  }
1584
+ $('btnSave').disabled = changed.length === 0 || !state.currentConfigName;
1585
+ $('btnDiscard').disabled = changed.length === 0;
1586
+
1587
+ const repo = state.config.TARGET_REPO;
1588
+ $('navSummaryRepo').textContent = repo || 'Aucun dépôt sélectionné';
1589
+ $('navSummaryOptions').textContent = [
1590
+ state.config.READ_ONLY ? 'Lecture seule' : 'Lecture-écriture',
1591
+ state.config.INCLUDE_PUBLIC ? 'dépôts publics inclus' : 'dépôts privés'
1592
+ ].join(' · ');
1593
+ $('navSummaryDeploy').textContent = state.config.LOCAL_DEPLOY
1594
+ ? 'Machine locale'
1595
+ : (state.config.SSH_HOST ? `SSH · ${state.config.SSH_HOST}` : 'SSH non configuré');
1596
+
1597
+ $('btnClone').disabled = !state.config.TARGET_REPO || !state.config.TARGET_BRANCH;
1598
+ $('btnClean').disabled = !state.extras.REPO_PATH;
1599
+
1600
+ const preview = $('cmdPreview');
1601
+ if (preview) preview.textContent = buildCommandPreview();
1602
+
1603
+ renderSummaryCard();
713
1604
  }
714
1605
 
715
- // Render select field
716
- function renderSelectField(field, value, config) {
717
- let options = '<option value="">— Sélectionner une option —</option>';
1606
+ function buildCommandPreview() {
1607
+ const command = state.config.TEST_COMMAND || 'npm test';
1608
+ const dir = state.config.SSH_REMOTE_PATH || '<chemin du projet>';
1609
+ if (state.config.LOCAL_DEPLOY) return `Sera exécuté : cd ${dir} && ${command}`;
1610
+ const port = state.config.SSH_PORT || '22';
1611
+ const user = state.config.SSH_USER || '<utilisateur>';
1612
+ const host = state.config.SSH_HOST || '<hôte>';
1613
+ return `Sera exécuté : ssh -p ${port} ${user}@${host} "cd ${dir} && ${command}"`;
1614
+ }
718
1615
 
719
- if (field.id === 'TARGET_REPO') {
720
- if (selectState.loadingRepos && selectState.repos.length === 0) {
721
- return `
722
- <div>
723
- <label for="${field.id}" class="field-label">${field.label}</label>
724
- <div id="${field.id}_loading" class="field-input flex items-center gap-2 text-dim">
725
- <div class="spinner"></div>
726
- Chargement des dépôts...
727
- </div>
728
- </div>
729
- `;
730
- }
1616
+ function renderSummaryCard() {
1617
+ const container = $('summaryRows');
1618
+ if (!container) return;
1619
+
1620
+ const base = state.config.BASE_BRANCH;
1621
+ const target = state.config.TARGET_BRANCH;
1622
+ const rows = [
1623
+ ['Dépôt', state.config.TARGET_REPO || 'Non défini'],
1624
+ ['Branches', base || target ? `${base || '—'} → ${target || '—'}` : 'Non définies'],
1625
+ ['Mode', state.config.READ_ONLY ? 'Lecture seule' : 'Lecture-écriture'],
1626
+ ['Déploiement', state.config.LOCAL_DEPLOY ? 'Machine locale' : (state.config.SSH_HOST ? `SSH · ${state.config.SSH_HOST}` : 'SSH non configuré')]
1627
+ ];
1628
+ if (state.extras.CLONE_DIR) rows.push(['Répertoire de clonage', state.extras.CLONE_DIR]);
1629
+
1630
+ container.innerHTML = rows.map(([label, value]) => `
1631
+ <div class="summary-row">
1632
+ <div class="sum-label">${esc(label)}</div>
1633
+ <div class="sum-value">${esc(value)}</div>
1634
+ </div>
1635
+ `).join('');
731
1636
 
732
- options += selectState.repos.map(repo =>
733
- `<option value="${repo}" ${value === repo ? 'selected' : ''}>${repo}</option>`
734
- ).join('');
1637
+ const notice = $('summaryNotice');
1638
+ if (notice) notice.hidden = changedFields().length === 0;
1639
+ }
735
1640
 
736
- if (value && !selectState.repos.includes(value)) {
737
- options += `<option value="${value}" selected>${value}</option>`;
738
- }
1641
+ /* =============================================================== */
1642
+ /* Panneau — Dépôt & branches */
1643
+ /* =============================================================== */
1644
+
1645
+ function repoMetaLine(fullName) {
1646
+ const meta = state.repoMeta[fullName];
1647
+ if (!meta || meta.private === undefined) return '';
1648
+ const bits = [];
1649
+ bits.push(`<span class="chip-static">${icon(meta.private ? 'lock' : 'globe')}${meta.private ? 'Privé' : 'Public'}</span>`);
1650
+ if (meta.language) bits.push(`<span class="lang-dot"></span><span>${esc(meta.language)}</span>`);
1651
+ if (meta.updated_at) bits.push(`<span>· ${esc(timeAgo(meta.updated_at))}</span>`);
1652
+ return bits.join('');
1653
+ }
739
1654
 
740
- return `
741
- <div>
742
- <label for="${field.id}" class="field-label">${field.label}</label>
743
- <div class="relative">
744
- <div class="relative">
745
- <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
746
- <svg class="h-4 w-4 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
747
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
748
- </svg>
749
- </div>
750
- <input type="text" id="REPO_SEARCH" placeholder="Rechercher un dépôt..." class="field-input" style="padding-left:2.25rem" autocomplete="off" value="${value || ''}">
751
- <div id="REPO_LOADING_INDICATOR" class="absolute inset-y-0 right-0 pr-3 flex items-center hidden">
752
- <div class="spinner"></div>
753
- </div>
754
- </div>
755
- <div id="REPO_RESULTS" class="search-results hidden"></div>
756
- <input type="hidden" id="${field.id}" value="${value || ''}">
757
- </div>
1655
+ function renderRepoPanel() {
1656
+ const repo = state.config.TARGET_REPO;
1657
+ const base = state.config.BASE_BRANCH;
1658
+ const target = state.config.TARGET_BRANCH;
1659
+ const branches = state.branches[repo] || [];
1660
+ const targetIsNew = !!target && branches.length > 0 && !branches.includes(target);
1661
+ const loading = !!state.loadingBranches[repo];
1662
+
1663
+ const repoCard = repo ? `
1664
+ <div class="card">
1665
+ <div class="repo-row">
1666
+ <span class="repo-avatar">${icon('book')}</span>
1667
+ <span class="repo-info">
1668
+ <div class="repo-name">${esc(repo)}</div>
1669
+ <div class="repo-meta">${repoMetaLine(repo)}</div>
1670
+ </span>
1671
+ <button type="button" class="btn btn-ghost btn-lg" id="btnChangeRepo">Changer de dépôt</button>
758
1672
  </div>
759
- `;
760
- } else if (field.id === 'BASE_BRANCH') {
761
- const currentRepo = config.TARGET_REPO;
762
- const branches = selectState.branches[currentRepo] || [];
763
-
764
- if (selectState.loadingBranches[currentRepo]) {
765
- return `
766
- <div>
767
- <label for="${field.id}" class="field-label">${field.label}</label>
768
- <div id="${field.id}_loading" class="field-input flex items-center gap-2 text-dim">
769
- <div class="spinner"></div>
770
- Chargement des branches...
771
- </div>
772
- </div>
773
- `;
774
- }
775
-
776
- // Recréer les options avec l'option par défaut sélectionnée si value est vide
777
- let branchOptions = `<option value="" ${!value ? 'selected' : ''}>— Choisir une branche —</option>`;
778
- branchOptions += branches.map(branch =>
779
- `<option value="${branch}" ${value === branch ? 'selected' : ''}>${branch}</option>`
780
- ).join('');
781
-
782
- return `
783
- <div>
784
- <label for="${field.id}" class="field-label">${field.label}</label>
785
- <select id="${field.id}" class="field-select">
786
- ${branchOptions}
787
- </select>
1673
+ </div>` : `
1674
+ <div class="card">
1675
+ <div class="empty-state">
1676
+ <span class="repo-avatar">${icon('book')}</span>
1677
+ <span class="empty-text">
1678
+ <div class="empty-title">Aucun dépôt sélectionné</div>
1679
+ <div class="empty-help">Choisissez le dépôt GitHub sur lequel le serveur MCP doit travailler.</div>
1680
+ </span>
1681
+ <button type="button" class="btn btn-primary btn-lg" id="btnChangeRepo">Choisir un dépôt</button>
788
1682
  </div>
789
- `;
790
- } else if (field.id === 'TARGET_BRANCH') {
791
- const currentRepo = config.TARGET_REPO;
792
- const branches = selectState.branches[currentRepo] || [];
793
-
794
- if (selectState.loadingBranches[currentRepo]) {
795
- return `
796
- <div>
797
- <label for="${field.id}" class="field-label">${field.label}</label>
798
- <div id="${field.id}_loading" class="field-input flex items-center gap-2 text-dim">
799
- <div class="spinner"></div>
800
- Chargement des branches...
1683
+ </div>`;
1684
+
1685
+ const pickerValue = (value, placeholder) => value
1686
+ ? `<span class="name">${esc(value)}</span>`
1687
+ : `<span class="name placeholder">${esc(placeholder)}</span>`;
1688
+
1689
+ $('panel-repo').innerHTML = `
1690
+ <h1 class="page-title">Dépôt &amp; branches</h1>
1691
+ <p class="page-subtitle">Le dépôt sur lequel le serveur MCP opère, et les deux branches qu'il utilise.</p>
1692
+
1693
+ <div class="layout">
1694
+ <div class="col-main">
1695
+ ${repoCard}
1696
+
1697
+ <div class="card">
1698
+ <h2 class="card-title">Branches</h2>
1699
+ <p class="card-help">La branche de travail est créée à partir de la branche de base.</p>
1700
+
1701
+ <div class="branch-flow">
1702
+ <button type="button" class="branch-picker" id="pickBase" ${repo && !loading ? '' : 'disabled'}>
1703
+ <span class="pick-label">Branche de base</span>
1704
+ <span class="pick-value">
1705
+ ${icon('branch')}
1706
+ ${loading ? '<span class="name placeholder">Chargement…</span>' : pickerValue(base, 'Choisir une branche')}
1707
+ <span class="chevrons">${icon('chevrons')}</span>
1708
+ </span>
1709
+ </button>
1710
+
1711
+ <span class="branch-arrow">${icon('arrowRight', 'ic-xl')}</span>
1712
+
1713
+ <button type="button" class="branch-picker is-target" id="pickTarget" ${repo && !loading ? '' : 'disabled'}>
1714
+ <span class="pick-label">Branche de travail</span>
1715
+ <span class="pick-value">
1716
+ ${icon('branch')}
1717
+ ${loading ? '<span class="name placeholder">Chargement…</span>' : pickerValue(target, 'Choisir ou créer')}
1718
+ ${targetIsNew ? '<span class="badge-new">nouvelle</span>' : ''}
1719
+ <span class="chevrons">${icon('chevrons')}</span>
1720
+ </span>
1721
+ </button>
801
1722
  </div>
802
- </div>
803
- `;
804
- }
805
1723
 
806
- let selectOptions = '<option value="">— Créer une nouvelle branche —</option>';
807
- selectOptions += branches.map(branch =>
808
- `<option value="${branch}" ${value === branch ? 'selected' : ''}>${branch}</option>`
809
- ).join('');
810
-
811
- const isCustom = value && !branches.includes(value);
812
- const shouldShowCustom = isCustom || value === '';
813
-
814
- return `
815
- <div>
816
- <label for="${field.id}" class="field-label">${field.label}</label>
817
- <div class="space-y-2">
818
- <select id="${field.id}" class="field-select">
819
- ${selectOptions}
820
- </select>
821
- <input type="text" id="${field.id}_custom" placeholder="Entrez le nom de la nouvelle branche" value="${isCustom ? value : ''}" class="field-input ${shouldShowCustom ? '' : 'hidden'}">
1724
+ <p class="flow-hint">${
1725
+ !repo
1726
+ ? 'Sélectionnez d\u2019abord un dépôt pour charger ses branches.'
1727
+ : targetIsNew
1728
+ ? `«&nbsp;${esc(target)}&nbsp;» n\u2019existe pas encore sur le dépôt : elle sera créée à partir de ${esc(base || 'la branche de base')} au premier commit.`
1729
+ : 'Les deux branches existent déjà sur le dépôt.'
1730
+ }</p>
822
1731
  </div>
823
1732
  </div>
824
- `;
825
- }
826
1733
 
827
- return `
828
- <div>
829
- <label for="${field.id}" class="field-label">${field.label}</label>
830
- <select id="${field.id}" class="field-select">
831
- ${options}
832
- </select>
1734
+ <div class="col-side">
1735
+ <div class="card">
1736
+ <div class="side-label">Résumé effectif</div>
1737
+ <div id="summaryRows" style="margin-top:12px"></div>
1738
+ <div class="notice" id="summaryNotice" hidden>
1739
+ ${icon('alert')}
1740
+ <span>Redémarrage requis</span>
1741
+ </div>
1742
+ </div>
1743
+ </div>
833
1744
  </div>
834
1745
  `;
1746
+
1747
+ const changeRepo = $('btnChangeRepo');
1748
+ if (changeRepo) changeRepo.addEventListener('click', openRepoPalette);
1749
+ const pickBase = $('pickBase');
1750
+ if (pickBase) pickBase.addEventListener('click', () => openBranchPalette('BASE_BRANCH'));
1751
+ const pickTarget = $('pickTarget');
1752
+ if (pickTarget) pickTarget.addEventListener('click', () => openBranchPalette('TARGET_BRANCH'));
835
1753
  }
836
1754
 
837
- // Fetch current config
838
- async function fetchConfig() {
839
- try {
840
- // Fetch configs list first
841
- await fetchConfigs();
1755
+ function openRepoPalette() {
1756
+ openPalette({
1757
+ placeholder: 'Rechercher un dépôt…',
1758
+ groupLabel: 'Vos dépôts',
1759
+ footerHint: 'Recherche côté GitHub',
1760
+ emptyLabel: 'Aucun dépôt ne correspond',
1761
+ remote: true,
1762
+ filters: [
1763
+ {
1764
+ label: 'Dépôts publics',
1765
+ on: !!state.config.INCLUDE_PUBLIC,
1766
+ onToggle: (on) => setIncludeFlag('INCLUDE_PUBLIC', on)
1767
+ },
1768
+ {
1769
+ label: 'Autres propriétaires',
1770
+ on: !!state.config.INCLUDE_NOT_OWNED,
1771
+ onToggle: (on) => setIncludeFlag('INCLUDE_NOT_OWNED', on)
1772
+ }
1773
+ ],
1774
+ source: async (query) => {
1775
+ const repos = await fetchRepositories(query);
1776
+ return repos.map((repo) => ({
1777
+ id: repo.full_name,
1778
+ title: repo.full_name,
1779
+ subtitle: [
1780
+ repo.private === undefined ? null : (repo.private ? 'Privé' : 'Public'),
1781
+ repo.language || null,
1782
+ repo.updated_at ? timeAgo(repo.updated_at) : null
1783
+ ].filter(Boolean).join(' · '),
1784
+ badge: repo.full_name === state.config.TARGET_REPO
1785
+ ? 'actuel'
1786
+ : (repo.owned === false ? 'non possédé' : null),
1787
+ current: repo.full_name === state.config.TARGET_REPO
1788
+ }));
1789
+ },
1790
+ onPick: selectRepository
1791
+ });
1792
+ }
842
1793
 
843
- const response = await fetch(api('api/config'));
844
- if (!response.ok) {
845
- const data = await response.json().catch(() => ({ error: 'Erreur lors du chargement' }));
846
- throw new Error(data.error || 'Erreur lors du chargement');
847
- }
848
- currentConfig = await response.json();
849
-
850
- // Afficher le nom de l'agent si défini
851
- const agentBadge = document.getElementById('agentNameBadge');
852
- if (currentConfig.AGENT_NAME) {
853
- agentBadge.textContent = currentConfig.AGENT_NAME;
854
- agentBadge.classList.remove('hidden');
855
- } else {
856
- agentBadge.classList.add('hidden');
857
- }
1794
+ async function selectRepository(fullName) {
1795
+ if (fullName === state.config.TARGET_REPO) return;
1796
+ state.config.TARGET_REPO = fullName;
1797
+ state.config.BASE_BRANCH = '';
1798
+ state.config.TARGET_BRANCH = '';
1799
+ renderRepoPanel();
1800
+ refreshDerived();
1801
+
1802
+ await fetchBranches(fullName);
1803
+ const branches = state.branches[fullName] || [];
1804
+ if (branches.includes('main')) state.config.BASE_BRANCH = 'main';
1805
+ else if (branches.includes('master')) state.config.BASE_BRANCH = 'master';
1806
+ else if (branches.length) state.config.BASE_BRANCH = branches[0];
1807
+
1808
+ renderRepoPanel();
1809
+ refreshDerived();
1810
+ }
858
1811
 
859
- // Le token est désormais côté serveur, on tente de charger les repos directement
860
- await fetchRepositories();
861
- if (currentConfig.TARGET_REPO) {
862
- await fetchBranches(currentConfig.TARGET_REPO);
1812
+ function openBranchPalette(fieldId) {
1813
+ const repo = state.config.TARGET_REPO;
1814
+ const branches = state.branches[repo] || [];
1815
+ const isTarget = fieldId === 'TARGET_BRANCH';
1816
+
1817
+ openPalette({
1818
+ placeholder: isTarget ? 'Rechercher ou nommer une branche…' : 'Rechercher une branche…',
1819
+ groupLabel: 'Branches du dépôt',
1820
+ footerHint: repo,
1821
+ emptyLabel: isTarget ? 'Tapez un nom pour créer la branche' : 'Aucune branche',
1822
+ createLabel: isTarget ? 'Créer la branche «\u00a0%s\u00a0»' : null,
1823
+ validateCreate: (value) => /^[\w.\-\/]+$/.test(value),
1824
+ source: async () => branches.map((branch) => ({
1825
+ id: branch,
1826
+ title: branch,
1827
+ iconName: 'branch',
1828
+ badge: branch === state.config[fieldId] ? 'actuel' : null,
1829
+ current: branch === state.config[fieldId]
1830
+ })),
1831
+ onPick: (value) => {
1832
+ state.config[fieldId] = value;
1833
+ renderRepoPanel();
1834
+ refreshDerived();
863
1835
  }
864
-
865
- renderInputs(currentConfig);
866
- } catch (error) {
867
- showStatus(error.message || 'Erreur lors du chargement de la configuration', 'error');
868
- }
1836
+ });
869
1837
  }
870
1838
 
871
- // Render a single field based on its type
872
- function renderField(field, config) {
873
- if (field.type === 'slider') {
874
- return `
875
- <div class="toggle-row">
876
- <label for="${field.id}" class="text">${field.label}</label>
877
- <label class="switch">
878
- <input type="checkbox" id="${field.id}" ${config[field.id] === 'true' || config[field.id] === true ? 'checked' : ''}>
879
- <span class="slider"></span>
880
- </label>
881
- </div>
882
- `;
1839
+ /* =============================================================== */
1840
+ /* Panneau — Options */
1841
+ /* =============================================================== */
1842
+
1843
+ const OPTION_ROWS = [
1844
+ {
1845
+ id: 'READ_ONLY',
1846
+ title: 'Mode lecture seule',
1847
+ help: 'Le serveur peut lire le dépôt mais ne pousse ni commit ni branche.'
1848
+ },
1849
+ {
1850
+ id: 'INCLUDE_PUBLIC',
1851
+ title: 'Inclure les dépôts publics',
1852
+ help: 'Sans cette option, seuls vos dépôts privés apparaissent dans la recherche.'
1853
+ },
1854
+ {
1855
+ id: 'INCLUDE_NOT_OWNED',
1856
+ title: 'Inclure les dépôts d\u2019autres propriétaires',
1857
+ help: 'Ajoute les dépôts où vous êtes collaborateur ou membre d\u2019une organisation.'
883
1858
  }
884
- if (field.type === 'checkbox') {
885
- return `
886
- <div class="toggle-row">
887
- <label for="${field.id}" class="text">${field.label}</label>
888
- <input type="checkbox" id="${field.id}" ${config[field.id] === 'true' || config[field.id] === true ? 'checked' : ''} class="w-4 h-4">
1859
+ ];
1860
+
1861
+ function renderOptionsPanel() {
1862
+ $('panel-options').innerHTML = `
1863
+ <h1 class="page-title">Options</h1>
1864
+ <p class="page-subtitle">Ce que le serveur MCP a le droit de faire, et ce qu'il vous propose.</p>
1865
+
1866
+ <div class="layout is-single" style="max-width:800px">
1867
+ <div class="col-main">
1868
+ <div class="card">
1869
+ ${OPTION_ROWS.map((row) => `
1870
+ <div class="toggle-row">
1871
+ <span class="toggle-text">
1872
+ <div class="toggle-title">${esc(row.title)}</div>
1873
+ <div class="toggle-help">${esc(row.help)}</div>
1874
+ </span>
1875
+ <label class="switch">
1876
+ <input type="checkbox" data-toggle="${row.id}" ${state.config[row.id] ? 'checked' : ''}>
1877
+ <span class="slider"></span>
1878
+ </label>
1879
+ </div>
1880
+ `).join('')}
1881
+ </div>
889
1882
  </div>
890
- `;
891
- }
892
- if (field.type === 'select') {
893
- return renderSelectField(field, config[field.id] || '', config);
894
- }
895
- const wrapperClass = sshOnlyFields.includes(field.id) ? 'ssh-only' : '';
896
- return `
897
- <div class="${wrapperClass}">
898
- <label for="${field.id}" class="field-label">${field.label}</label>
899
- <input type="${field.type}" id="${field.id}" value="${config[field.id] || ''}" placeholder="${field.placeholder}" class="field-input">
900
1883
  </div>
901
1884
  `;
1885
+
1886
+ $('panel-options').querySelectorAll('[data-toggle]').forEach((input) => {
1887
+ input.addEventListener('change', () => {
1888
+ const id = input.dataset.toggle;
1889
+ if (id === 'INCLUDE_PUBLIC' || id === 'INCLUDE_NOT_OWNED') {
1890
+ setIncludeFlag(id, input.checked);
1891
+ } else {
1892
+ setField(id, input.checked);
1893
+ }
1894
+ });
1895
+ });
1896
+ }
1897
+
1898
+ async function setIncludeFlag(id, value) {
1899
+ state.config[id] = value;
1900
+ refreshDerived();
1901
+ if (state.panel === 'options') {
1902
+ const input = document.querySelector(`[data-toggle="${id}"]`);
1903
+ if (input) input.checked = value;
1904
+ }
1905
+ try {
1906
+ await request('api/config', {
1907
+ method: 'POST',
1908
+ headers: { 'Content-Type': 'application/json' },
1909
+ body: JSON.stringify({ [id]: String(value) })
1910
+ });
1911
+ } catch (error) {
1912
+ toast({ type: 'error', title: 'Filtre non appliqué', detail: error.message });
1913
+ }
902
1914
  }
903
1915
 
904
- // Render inputs grouped into (collapsible) sections
905
- function renderInputs(config) {
906
- // CLONE_DIR en lecture seule (variable d'environnement)
907
- const cloneDirInfo = config.CLONE_DIR
908
- ? `<div class="info-row">
909
- <span class="text-xs font-medium text-dim">Répertoire de clonage</span>
910
- <span class="text-xs text-main font-mono">${config.CLONE_DIR}</span>
911
- </div>`
1916
+ /* =============================================================== */
1917
+ /* Panneau — Déploiement & test */
1918
+ /* =============================================================== */
1919
+
1920
+ function renderDeployPanel() {
1921
+ const local = !!state.config.LOCAL_DEPLOY;
1922
+ const test = state.sshTest;
1923
+
1924
+ const testResult = test
1925
+ ? `<span class="test-result ${test.pending ? 'is-pending' : test.ok ? 'is-ok' : 'is-ko'}">
1926
+ ${test.pending ? '<span class="spinner"></span>' : icon(test.ok ? 'check' : 'cross')}
1927
+ <span>${esc(test.message)}</span>
1928
+ </span>`
912
1929
  : '';
913
1930
 
914
- inputsContainer.innerHTML = sections.map(section => {
915
- const bodyHtml = section.fields
916
- .map(fid => fieldsById[fid] ? renderField(fieldsById[fid], config) : '')
917
- .join('');
1931
+ $('panel-deploy').innerHTML = `
1932
+ <h1 class="page-title">Déploiement &amp; test</h1>
1933
+ <p class="page-subtitle">Où le serveur applique les changements, et comment il vérifie qu'ils passent.</p>
1934
+
1935
+ <div class="layout">
1936
+ <div class="col-main">
1937
+ <div class="card">
1938
+ <h2 class="card-title">Cible de déploiement</h2>
1939
+ <div class="segmented">
1940
+ <button type="button" data-target="local" class="${local ? 'is-active' : ''}">
1941
+ ${icon('drive')}<span>Même machine</span>
1942
+ </button>
1943
+ <button type="button" data-target="ssh" class="${local ? '' : 'is-active'}">
1944
+ ${icon('server')}<span>Serveur distant (SSH)</span>
1945
+ </button>
1946
+ </div>
1947
+ <p class="card-help" style="margin-top:16px">${
1948
+ local
1949
+ ? 'Les commandes sont exécutées sur la machine qui héberge le serveur MCP.'
1950
+ : 'Les commandes sont exécutées à distance. Les identifiants de connexion s\u2019affichent ci-dessous.'
1951
+ }</p>
1952
+ </div>
1953
+
1954
+ <div class="card" id="sshCard" ${local ? 'hidden' : ''}>
1955
+ <h2 class="card-title">Connexion SSH</h2>
1956
+
1957
+ <div class="field-grid" style="margin-top:20px">
1958
+ <div class="field" style="flex:2 1 260px">
1959
+ <label class="field-label" for="SSH_HOST">Hôte</label>
1960
+ <input class="input is-mono" id="SSH_HOST" data-text-field="SSH_HOST"
1961
+ value="${esc(state.config.SSH_HOST)}" placeholder="srv.example.com" autocomplete="off">
1962
+ </div>
1963
+ <div class="field" style="flex:1 1 140px">
1964
+ <label class="field-label" for="SSH_USER">Utilisateur</label>
1965
+ <input class="input is-mono" id="SSH_USER" data-text-field="SSH_USER"
1966
+ value="${esc(state.config.SSH_USER)}" placeholder="root" autocomplete="off">
1967
+ </div>
1968
+ <div class="field" style="flex:1 1 120px">
1969
+ <label class="field-label" for="SSH_PORT">Port</label>
1970
+ <input class="input is-mono" id="SSH_PORT" data-text-field="SSH_PORT"
1971
+ value="${esc(state.config.SSH_PORT)}" placeholder="22" autocomplete="off">
1972
+ </div>
1973
+ </div>
1974
+
1975
+ <div class="field" style="margin-top:20px">
1976
+ <label class="field-label" for="SSH_KEY_PATH">Clé privée</label>
1977
+ <input class="input is-mono" id="SSH_KEY_PATH" data-text-field="SSH_KEY_PATH"
1978
+ value="${esc(state.config.SSH_KEY_PATH)}"
1979
+ placeholder="C:\\Users\\moi\\.ssh\\id_ed25519" autocomplete="off">
1980
+ </div>
918
1981
 
919
- if (!section.collapsible) {
920
- return `
921
- <div class="form-section">
922
- <div class="section-static-header">${section.title}</div>
923
- <div class="section-body space-y-4">${bodyHtml}</div>
1982
+ <div class="test-row">
1983
+ <button type="button" class="btn btn-soft btn-lg" id="btnSshTest">
1984
+ ${icon('plug')}<span>Tester la connexion</span>
1985
+ </button>
1986
+ ${testResult}
1987
+ </div>
924
1988
  </div>
925
- `;
926
- }
927
1989
 
928
- const open = !!sectionState[section.id];
929
- return `
930
- <div class="form-section">
931
- <button type="button" class="section-header ${open ? 'open' : ''}" data-section="${section.id}">
932
- <span>${section.title}</span>
933
- <svg class="chevron w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
934
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
935
- </svg>
936
- </button>
937
- <div class="section-body space-y-4 ${open ? '' : 'hidden'}" data-section-body="${section.id}">
938
- ${bodyHtml}
1990
+ <div class="card">
1991
+ <h2 class="card-title">Projet &amp; tests</h2>
1992
+
1993
+ <div class="field" style="margin-top:20px">
1994
+ <label class="field-label" for="SSH_REMOTE_PATH">Chemin du projet sur la cible</label>
1995
+ <input class="input is-mono" id="SSH_REMOTE_PATH" data-text-field="SSH_REMOTE_PATH"
1996
+ value="${esc(state.config.SSH_REMOTE_PATH)}"
1997
+ placeholder="/var/www/mon-projet" autocomplete="off">
1998
+ </div>
1999
+
2000
+ <div class="field" style="margin-top:20px">
2001
+ <label class="field-label" for="TEST_COMMAND">Commande de test</label>
2002
+ <input class="input is-mono" id="TEST_COMMAND" data-text-field="TEST_COMMAND"
2003
+ value="${esc(state.config.TEST_COMMAND)}"
2004
+ placeholder="npm test" autocomplete="off">
2005
+ </div>
2006
+
2007
+ <p class="cmd-preview" id="cmdPreview"></p>
939
2008
  </div>
940
2009
  </div>
941
- `;
942
- }).join('') + cloneDirInfo;
943
2010
 
944
- if (deleteWorkingDirBtn) {
945
- deleteWorkingDirBtn.disabled = !config.REPO_PATH;
946
- }
2011
+ <div class="col-side">
2012
+ <div class="card">
2013
+ <div class="side-label">Journal d'activité</div>
2014
+ <div id="activityRows" style="margin-top:12px"></div>
2015
+ </div>
2016
+ </div>
2017
+ </div>
2018
+ `;
947
2019
 
948
- if (cloneBtn) {
949
- cloneBtn.disabled = !config.TARGET_REPO || !config.TARGET_BRANCH;
950
- }
2020
+ $('panel-deploy').querySelectorAll('[data-target]').forEach((button) => {
2021
+ button.addEventListener('click', () => {
2022
+ const wantLocal = button.dataset.target === 'local';
2023
+ if (wantLocal === !!state.config.LOCAL_DEPLOY) return;
2024
+ state.config.LOCAL_DEPLOY = wantLocal;
2025
+ renderDeployPanel();
2026
+ refreshDerived();
2027
+ });
2028
+ });
951
2029
 
952
- setupEventListeners();
2030
+ bindTextFields($('panel-deploy'));
2031
+
2032
+ const testBtn = $('btnSshTest');
2033
+ if (testBtn) testBtn.addEventListener('click', runSshTest);
2034
+
2035
+ renderActivity();
953
2036
  }
954
2037
 
955
- // Update select UI
956
- function updateSelectUI() {
957
- // Mettre à jour currentConfig à partir du DOM pour les éléments qui existent
958
- fields.forEach(field => {
959
- const input = document.getElementById(field.id);
960
- if (input) {
961
- if (field.type === 'checkbox' || field.type === 'slider') {
962
- currentConfig[field.id] = input.checked;
963
- } else if (field.id === 'TARGET_BRANCH') {
964
- const customInput = document.getElementById('TARGET_BRANCH_custom');
965
- currentConfig[field.id] = (input.value === '' && customInput && customInput.value) ? customInput.value : input.value;
966
- } else {
967
- currentConfig[field.id] = input.value;
968
- }
969
- }
970
- });
971
- renderInputs(currentConfig);
972
- }
973
-
974
- // Setup event listeners
975
- function setupEventListeners() {
976
- const targetBranchSelect = document.getElementById('TARGET_BRANCH');
977
- const repoSearch = document.getElementById('REPO_SEARCH');
978
- const resultsContainer = document.getElementById('REPO_RESULTS');
979
-
980
- // Sections repliables : bascule sans re-render pour préserver les saisies
981
- inputsContainer.querySelectorAll('.section-header').forEach(header => {
982
- header.addEventListener('click', () => {
983
- const id = header.dataset.section;
984
- sectionState[id] = !sectionState[id];
985
- header.classList.toggle('open', sectionState[id]);
986
- const body = inputsContainer.querySelector(`[data-section-body="${id}"]`);
987
- if (body) body.classList.toggle('hidden', !sectionState[id]);
988
- });
2038
+ function bindTextFields(scope) {
2039
+ scope.querySelectorAll('[data-text-field]').forEach((input) => {
2040
+ input.addEventListener('input', () => setField(input.dataset.textField, input.value));
989
2041
  });
2042
+ }
990
2043
 
991
- // Mode de déploiement : masque les champs SSH quand le déploiement local est actif
992
- const localDeployToggle = document.getElementById('LOCAL_DEPLOY');
993
- if (localDeployToggle) {
994
- const applyDeployMode = (isLocal) => {
995
- inputsContainer.querySelectorAll('.ssh-only').forEach(el => el.classList.toggle('hidden', isLocal));
996
- };
997
- applyDeployMode(localDeployToggle.checked);
998
- localDeployToggle.addEventListener('change', () => applyDeployMode(localDeployToggle.checked));
999
- }
2044
+ function renderActivity() {
2045
+ const container = $('activityRows');
2046
+ if (!container) return;
1000
2047
 
1001
- if (repoSearch) {
1002
- // Focus: ouvre la liste avec ce qui est déjà là
1003
- repoSearch.addEventListener('focus', () => {
1004
- if (selectState.repos.length > 0) {
1005
- renderRepoResults();
1006
- } else {
1007
- // Si vraiment vide, on tente de charger une fois
1008
- fetchRepositories();
1009
- }
1010
- });
2048
+ if (!state.activity.length) {
2049
+ container.innerHTML = `<div class="log-empty">Les actions de cette session s'afficheront ici.</div>`;
2050
+ return;
2051
+ }
1011
2052
 
1012
- // Clavier: navigation avec flèches et Enter
1013
- repoSearch.addEventListener('keydown', (e) => {
1014
- const items = resultsContainer.querySelectorAll('.search-result-item');
1015
- if (items.length === 0) return;
1016
-
1017
- if (e.key === 'ArrowDown') {
1018
- e.preventDefault();
1019
- activeIndex = Math.min(activeIndex + 1, items.length - 1);
1020
- updateActiveItem(items);
1021
- } else if (e.key === 'ArrowUp') {
1022
- e.preventDefault();
1023
- activeIndex = Math.max(activeIndex - 1, 0);
1024
- updateActiveItem(items);
1025
- } else if (e.key === 'Enter') {
1026
- e.preventDefault();
1027
- if (activeIndex >= 0) {
1028
- selectRepo(selectState.repos[activeIndex]);
1029
- }
1030
- } else if (e.key === 'Escape') {
1031
- resultsContainer.classList.add('hidden');
1032
- }
1033
- });
2053
+ const iconFor = { ok: 'check', ko: 'cross', info: 'refresh', neutral: 'download' };
2054
+ container.innerHTML = state.activity.map((entry) => `
2055
+ <div class="log-entry is-${entry.tone}">
2056
+ ${icon(iconFor[entry.tone] || 'info')}
2057
+ <span class="log-label">${esc(entry.label)}</span>
2058
+ <span class="log-time">${esc(timeAgo(entry.at))}</span>
2059
+ </div>
2060
+ `).join('');
2061
+ }
1034
2062
 
1035
- repoSearch.addEventListener('input', (e) => {
1036
- const term = e.target.value;
1037
- clearTimeout(searchTimeout);
1038
- searchTimeout = setTimeout(() => {
1039
- fetchRepositories(term);
1040
- }, 500);
2063
+ async function runSshTest() {
2064
+ state.sshTest = { pending: true, message: 'Connexion en cours…' };
2065
+ renderDeployPanel();
2066
+ try {
2067
+ const result = await request('api/ssh-test', {
2068
+ method: 'POST',
2069
+ headers: { 'Content-Type': 'application/json' },
2070
+ body: JSON.stringify({
2071
+ SSH_HOST: state.config.SSH_HOST,
2072
+ SSH_USER: state.config.SSH_USER,
2073
+ SSH_PORT: state.config.SSH_PORT,
2074
+ SSH_KEY_PATH: state.config.SSH_KEY_PATH,
2075
+ SSH_REMOTE_PATH: state.config.SSH_REMOTE_PATH
2076
+ })
1041
2077
  });
2078
+ state.sshTest = { ok: true, message: result.message || 'Connexion établie' };
2079
+ logActivity('ok', 'Connexion SSH vérifiée');
2080
+ } catch (error) {
2081
+ state.sshTest = { ok: false, message: error.message };
2082
+ logActivity('ko', `Test SSH échoué : ${error.message}`);
1042
2083
  }
2084
+ renderDeployPanel();
2085
+ }
1043
2086
 
1044
- function updateActiveItem(items) {
1045
- items.forEach((item, index) => {
1046
- item.classList.toggle('active', index === activeIndex);
1047
- if (index === activeIndex) {
1048
- item.scrollIntoView({ block: 'nearest' });
1049
- }
1050
- });
1051
- }
2087
+ /* =============================================================== */
2088
+ /* Navigation entre panneaux */
2089
+ /* =============================================================== */
1052
2090
 
1053
- document.addEventListener('click', (e) => {
1054
- if (resultsContainer && !resultsContainer.contains(e.target) && e.target !== repoSearch) {
1055
- resultsContainer.classList.add('hidden');
1056
- }
2091
+ function showPanel(name) {
2092
+ state.panel = name;
2093
+ ['repo', 'options', 'deploy'].forEach((key) => {
2094
+ $(`panel-${key}`).hidden = key !== name;
1057
2095
  });
2096
+ document.querySelectorAll('.nav-item').forEach((item) => {
2097
+ item.classList.toggle('is-active', item.dataset.panel === name);
2098
+ });
2099
+
2100
+ if (name === 'repo') renderRepoPanel();
2101
+ if (name === 'options') renderOptionsPanel();
2102
+ if (name === 'deploy') renderDeployPanel();
2103
+ refreshDerived();
2104
+ }
1058
2105
 
1059
- ['INCLUDE_PUBLIC', 'INCLUDE_NOT_OWNED'].forEach(id => {
1060
- const input = document.getElementById(id);
1061
- if (input) {
1062
- input.addEventListener('change', async () => {
1063
- const config = {};
1064
- fields.forEach(f => {
1065
- const inp = document.getElementById(f.id);
1066
- if (inp) {
1067
- if (f.type === 'checkbox' || f.type === 'slider') config[f.id] = inp.checked;
1068
- else config[f.id] = inp.value;
1069
- }
1070
- });
1071
-
1072
- try {
1073
- await fetch(api('api/config'), {
1074
- method: 'POST',
1075
- headers: { 'Content-Type': 'application/json' },
1076
- body: JSON.stringify(config)
1077
- });
1078
- await fetchRepositories(repoSearch?.value || '');
1079
- } catch (e) {
1080
- console.error('Erreur lors de la mise à jour auto:', e);
1081
- }
1082
- });
2106
+ /* =============================================================== */
2107
+ /* Configurations */
2108
+ /* =============================================================== */
2109
+
2110
+ function openConfigPalette() {
2111
+ openPalette({
2112
+ placeholder: 'Rechercher une configuration…',
2113
+ groupLabel: 'Configurations',
2114
+ footerHint: 'Une configuration par projet',
2115
+ emptyLabel: 'Aucune configuration',
2116
+ createLabel: 'Créer la configuration «\u00a0%s\u00a0»',
2117
+ validateCreate: (value) => !state.configNames.includes(value),
2118
+ source: async () => state.configNames.map((name) => ({
2119
+ id: name,
2120
+ title: name,
2121
+ iconName: 'folder',
2122
+ badge: name === state.currentConfigName ? 'actuelle' : null,
2123
+ current: name === state.currentConfigName
2124
+ })),
2125
+ onPick: async (value, item) => {
2126
+ if (item.isCreate) await createConfiguration(value);
2127
+ else if (value !== state.currentConfigName) await activateConfiguration(value);
1083
2128
  }
1084
2129
  });
2130
+ }
1085
2131
 
1086
- if (targetBranchSelect) {
1087
- targetBranchSelect.addEventListener('change', () => {
1088
- const customInput = document.getElementById('TARGET_BRANCH_custom');
1089
- if (!customInput) return;
1090
-
1091
- const isNewBranch = targetBranchSelect.value === '';
1092
- customInput.classList.toggle('hidden', !isNewBranch);
1093
- if (isNewBranch) {
1094
- customInput.focus();
1095
- } else {
1096
- customInput.value = '';
1097
- }
1098
- });
2132
+ async function activateConfiguration(name) {
2133
+ try {
2134
+ await request(`api/configs/${encodeURIComponent(name)}/activate`, { method: 'POST' });
2135
+ state.currentConfigName = name;
2136
+ state.branches = {};
2137
+ state.sshTest = null;
2138
+ await reloadEverything();
2139
+ toast({ type: 'success', title: `Configuration «\u00a0${name}\u00a0» activée` });
2140
+ logActivity('info', `Configuration «\u00a0${name}\u00a0» activée`);
2141
+ } catch (error) {
2142
+ toast({ type: 'error', title: 'Activation impossible', detail: error.message });
1099
2143
  }
1100
2144
  }
1101
2145
 
1102
- function showStatus(message, type) {
1103
- const container = document.getElementById('toast-container');
1104
- const toast = document.createElement('div');
1105
-
1106
- const icon = type === 'success'
1107
- ? '<svg class="w-4 h-4 shrink-0" style="color:var(--accent)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>'
1108
- : '<svg class="w-4 h-4 shrink-0" style="color:var(--danger)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>';
1109
-
1110
- toast.className = `toast ${type === 'success' ? 'toast-success' : 'toast-error'}`;
1111
- toast.innerHTML = `${icon}<span class="flex-1">${message}</span>`;
2146
+ async function createConfiguration(name) {
2147
+ const blank = {};
2148
+ FIELDS.forEach((field) => { blank[field.id] = field.type === 'bool' ? false : ''; });
2149
+ blank.READ_ONLY = state.config.READ_ONLY;
2150
+ blank.INCLUDE_PUBLIC = state.config.INCLUDE_PUBLIC;
2151
+ blank.INCLUDE_NOT_OWNED = state.config.INCLUDE_NOT_OWNED;
1112
2152
 
1113
- container.appendChild(toast);
1114
-
1115
- // Supprimer le toast après l'animation (5s total)
1116
- setTimeout(() => {
1117
- toast.remove();
1118
- }, 5000);
2153
+ try {
2154
+ await request('api/configs', {
2155
+ method: 'POST',
2156
+ headers: { 'Content-Type': 'application/json' },
2157
+ body: JSON.stringify({ name, config: blank })
2158
+ });
2159
+ await loadConfigs();
2160
+ await activateConfiguration(name);
2161
+ } catch (error) {
2162
+ toast({ type: 'error', title: 'Création impossible', detail: error.message });
2163
+ }
1119
2164
  }
1120
2165
 
1121
- // Uniformisation: addEventListener au lieu de onsubmit/onclick
1122
- form.addEventListener('submit', async (e) => {
1123
- e.preventDefault();
1124
-
1125
- // Check if a configuration is selected
1126
- if (!currentConfigName) {
1127
- showStatus('Veuillez sélectionner ou créer une configuration', 'error');
2166
+ async function saveConfiguration() {
2167
+ if (!state.currentConfigName) {
2168
+ toast({ type: 'error', title: 'Aucune configuration sélectionnée', detail: 'Créez-en une depuis la barre du haut.' });
1128
2169
  return;
1129
2170
  }
1130
2171
 
1131
- const config = {};
1132
- fields.forEach(field => {
1133
- const input = document.getElementById(field.id);
1134
- if (!input) return; // Protection contre les éléments manquants
1135
- if (field.type === 'checkbox' || field.type === 'slider') {
1136
- config[field.id] = input.checked;
1137
- } else if (field.id === 'TARGET_BRANCH') {
1138
- const customInput = document.getElementById(`${field.id}_custom`);
1139
- config[field.id] = customInput && customInput.value ? customInput.value : input.value;
1140
- } else {
1141
- config[field.id] = input.value;
1142
- }
1143
- });
2172
+ const payload = {};
2173
+ FIELDS.forEach((field) => { payload[field.id] = state.config[field.id]; });
1144
2174
 
1145
2175
  try {
1146
- // Save to configs.json via the new endpoint
1147
- const response = await fetch(api('api/configs'), {
2176
+ await request('api/configs', {
1148
2177
  method: 'POST',
1149
2178
  headers: { 'Content-Type': 'application/json' },
1150
- body: JSON.stringify({ name: currentConfigName, config })
2179
+ body: JSON.stringify({ name: state.currentConfigName, config: payload })
2180
+ });
2181
+ state.saved = Object.assign({}, state.config);
2182
+ refreshDerived();
2183
+ logActivity('ok', 'Configuration enregistrée');
2184
+ toast({
2185
+ type: 'success',
2186
+ title: 'Configuration enregistrée',
2187
+ detail: 'Redémarrez le serveur pour l\u2019appliquer.',
2188
+ actionLabel: 'Redémarrer',
2189
+ onAction: restartServer
1151
2190
  });
1152
-
1153
- if (response.ok) {
1154
- showStatus(`Configuration "${currentConfigName}" enregistrée avec succès !`, 'success');
1155
- } else {
1156
- const data = await response.json().catch(() => ({ error: 'Erreur inconnue' }));
1157
- throw new Error(data.error || 'Erreur lors de l\'enregistrement');
1158
- }
1159
2191
  } catch (error) {
1160
- showStatus(error.message, 'error');
2192
+ toast({ type: 'error', title: 'Enregistrement impossible', detail: error.message });
1161
2193
  }
1162
- });
2194
+ }
1163
2195
 
1164
- restartBtn.addEventListener('click', async () => {
1165
- if (!confirm('Êtes-vous sûr de vouloir redémarrer le serveur MCP ? Cela coupera la connexion actuelle pour appliquer les changements.')) {
1166
- return;
1167
- }
2196
+ function discardChanges() {
2197
+ state.config = Object.assign({}, state.saved);
2198
+ showPanel(state.panel);
2199
+ toast({ type: 'info', title: 'Modifications annulées' });
2200
+ }
1168
2201
 
1169
- const originalHTML = restartBtn.innerHTML; // Sauvegarder le HTML original
1170
- try {
1171
- restartBtn.disabled = true;
1172
- restartBtn.innerHTML = '<span>Redémarrage en cours...</span>';
2202
+ /* =============================================================== */
2203
+ /* Actions serveur */
2204
+ /* =============================================================== */
2205
+
2206
+ async function restartServer() {
2207
+ const confirmed = await openModal({
2208
+ title: 'Redémarrer le serveur MCP ?',
2209
+ body: 'La connexion en cours sera coupée le temps du redémarrage, puis la page se rechargera.',
2210
+ confirmLabel: 'Redémarrer'
2211
+ });
2212
+ if (!confirmed) return;
1173
2213
 
1174
- const response = await fetch(api('api/restart'), { method: 'POST' });
2214
+ const label = $('btnRestartLabel');
2215
+ const original = label.textContent;
2216
+ label.textContent = 'Redémarrage…';
2217
+ $('btnRestart').disabled = true;
1175
2218
 
1176
- if (response.ok) {
1177
- showStatus('Signal de redémarrage envoyé. Le serveur va se relancer...', 'success');
1178
- setTimeout(() => window.location.reload(), 3000);
1179
- } else {
1180
- throw new Error('Erreur lors de la demande de redémarrage');
1181
- }
2219
+ try {
2220
+ await request('api/restart', { method: 'POST' });
2221
+ logActivity('info', 'Serveur MCP redémarré');
2222
+ toast({ type: 'success', title: 'Redémarrage lancé', detail: 'La page se recharge dans quelques secondes.' });
2223
+ setTimeout(() => window.location.reload(), 3000);
1182
2224
  } catch (error) {
1183
- showStatus(error.message, 'error');
1184
- restartBtn.disabled = false;
1185
- restartBtn.innerHTML = originalHTML; // Restaurer le HTML original
2225
+ toast({ type: 'error', title: 'Redémarrage impossible', detail: error.message });
2226
+ label.textContent = original;
2227
+ $('btnRestart').disabled = false;
1186
2228
  }
1187
- });
2229
+ }
1188
2230
 
1189
- stopBtn.addEventListener('click', async () => {
1190
- if (!confirm('Êtes-vous sûr de vouloir arrêter COMPLÈTEMENT le serveur MCP ?')) {
1191
- return;
1192
- }
2231
+ async function stopServer() {
2232
+ const confirmed = await openModal({
2233
+ variant: 'danger',
2234
+ title: 'Arrêter complètement le serveur MCP ?',
2235
+ body: 'Le serveur s\u2019arrête et cette page ne sera plus accessible. Il faudra le relancer depuis votre client MCP.',
2236
+ confirmLabel: 'Arrêter'
2237
+ });
2238
+ if (!confirmed) return;
1193
2239
 
1194
- const originalHTML = stopBtn.innerHTML; // Sauvegarder le HTML original
1195
2240
  try {
1196
- stopBtn.disabled = true;
1197
- stopBtn.innerHTML = '<span>Arrêt en cours...</span>';
2241
+ await request('api/stop', { method: 'POST' });
2242
+ state.serverOk = false;
2243
+ renderStatus();
2244
+ logActivity('ko', 'Serveur MCP arrêté');
2245
+ toast({ type: 'info', title: 'Le serveur s\u2019arrête', detail: 'Cette page ne sera plus disponible.', sticky: true });
2246
+ } catch (error) {
2247
+ toast({ type: 'error', title: 'Arrêt impossible', detail: error.message });
2248
+ }
2249
+ }
1198
2250
 
1199
- const response = await fetch(api('api/stop'), { method: 'POST' });
2251
+ async function cleanWorkingDir() {
2252
+ const repoName = (state.config.TARGET_REPO || '').split('/').pop() || 'le dépôt';
2253
+ const confirmed = await openModal({
2254
+ variant: 'danger',
2255
+ title: 'Supprimer le dossier de travail local ?',
2256
+ body: 'Le dossier sera supprimé du disque et les modifications non poussées seront perdues. Il faudra recloner le dépôt pour réutiliser les outils Git.',
2257
+ pathText: state.extras.REPO_PATH,
2258
+ confirmWord: repoName,
2259
+ confirmLabel: 'Supprimer'
2260
+ });
2261
+ if (!confirmed) return;
1200
2262
 
1201
- if (response.ok) {
1202
- showStatus('Le serveur s\'arrête. Cette page ne sera plus disponible.', 'success');
1203
- } else {
1204
- throw new Error('Erreur lors de la demande d\'arrêt');
1205
- }
2263
+ try {
2264
+ await request('api/delete-working-dir', { method: 'POST' });
2265
+ state.extras.REPO_PATH = '';
2266
+ refreshDerived();
2267
+ logActivity('ko', 'Dossier de travail supprimé');
2268
+ toast({ type: 'success', title: 'Dossier de travail supprimé' });
1206
2269
  } catch (error) {
1207
- showStatus(error.message, 'error');
1208
- stopBtn.disabled = false;
1209
- stopBtn.innerHTML = originalHTML; // Restaurer le HTML original
2270
+ toast({ type: 'error', title: 'Suppression impossible', detail: error.message });
1210
2271
  }
1211
- });
2272
+ }
1212
2273
 
1213
- deleteWorkingDirBtn.addEventListener('click', async () => {
1214
- if (!confirm('Êtes-vous sûr de vouloir SUPPRIMER physiquement le dossier de travail local ? Cette action est irréversible et vous devrez effectuer un nouveau "clone" pour utiliser les outils Git.')) {
1215
- return;
1216
- }
2274
+ async function cloneRepository() {
2275
+ const confirmed = await openModal({
2276
+ title: 'Cloner le dépôt ?',
2277
+ body: 'Le clonage se fait en arrière-plan dans le répertoire de clonage. Enregistrez vos modifications avant de lancer l\u2019opération.',
2278
+ pathText: state.extras.CLONE_DIR || undefined,
2279
+ confirmLabel: 'Cloner'
2280
+ });
2281
+ if (!confirmed) return;
2282
+
2283
+ const label = $('btnCloneLabel');
2284
+ const original = label.textContent;
2285
+ label.textContent = 'Clonage…';
2286
+ $('btnClone').disabled = true;
1217
2287
 
1218
- const originalHTML = deleteWorkingDirBtn.innerHTML;
1219
2288
  try {
1220
- deleteWorkingDirBtn.disabled = true;
1221
- deleteWorkingDirBtn.innerHTML = '<span>Suppression...</span>';
1222
-
1223
- const response = await fetch(api('api/delete-working-dir'), { method: 'POST' });
1224
-
1225
- if (response.ok) {
1226
- showStatus('Dossier de travail supprimé avec succès.', 'success');
1227
- currentConfig.REPO_PATH = '';
1228
- deleteWorkingDirBtn.disabled = true;
1229
- } else {
1230
- const data = await response.json().catch(() => ({ error: 'Erreur lors de la suppression' }));
1231
- throw new Error(data.error || 'Erreur lors de la suppression');
1232
- }
2289
+ await request('api/clone', { method: 'POST' });
2290
+ logActivity('neutral', `Clonage lancé pour ${state.config.TARGET_REPO}`);
2291
+ toast({ type: 'success', title: 'Clonage lancé', detail: 'Le processus tourne en arrière-plan.' });
1233
2292
  } catch (error) {
1234
- showStatus(error.message, 'error');
2293
+ toast({ type: 'error', title: 'Clonage impossible', detail: error.message });
1235
2294
  } finally {
1236
- deleteWorkingDirBtn.innerHTML = originalHTML;
1237
- deleteWorkingDirBtn.disabled = !currentConfig.REPO_PATH;
2295
+ label.textContent = original;
2296
+ refreshDerived();
1238
2297
  }
1239
- });
2298
+ }
1240
2299
 
1241
- cloneBtn.addEventListener('click', async () => {
1242
- if (!confirm('Voulez-vous lancer le clonage du dépôt ? Assurez-vous d\'avoir enregistré la configuration (Parent Projects Path) avant.')) {
1243
- return;
2300
+ /* =============================================================== */
2301
+ /* Démarrage */
2302
+ /* =============================================================== */
2303
+
2304
+ async function reloadEverything() {
2305
+ await loadConfigs();
2306
+ await loadConfig();
2307
+ renderChrome();
2308
+
2309
+ if (state.config.TARGET_REPO) {
2310
+ fetchBranches(state.config.TARGET_REPO).then(() => {
2311
+ if (state.panel === 'repo') renderRepoPanel();
2312
+ refreshDerived();
2313
+ });
1244
2314
  }
1245
2315
 
1246
- const originalHTML = cloneBtn.innerHTML;
1247
- try {
1248
- cloneBtn.disabled = true;
1249
- cloneBtn.innerHTML = '<span>Clonage...</span>';
2316
+ fetchRepositories().catch(() => { /* la palette signalera l'erreur */ });
2317
+ showPanel(state.panel);
2318
+ }
2319
+
2320
+ function bindChrome() {
2321
+ $('configSwitcher').addEventListener('click', openConfigPalette);
2322
+ $('btnSave').addEventListener('click', saveConfiguration);
2323
+ $('btnDiscard').addEventListener('click', discardChanges);
2324
+ $('btnClone').addEventListener('click', cloneRepository);
2325
+ $('btnRestart').addEventListener('click', restartServer);
2326
+ $('btnClean').addEventListener('click', cleanWorkingDir);
2327
+ $('btnStop').addEventListener('click', stopServer);
2328
+
2329
+ document.querySelectorAll('.nav-item').forEach((item) => {
2330
+ item.addEventListener('click', () => showPanel(item.dataset.panel));
2331
+ });
1250
2332
 
1251
- const response = await fetch(api('api/clone'), { method: 'POST' });
2333
+ document.addEventListener('keydown', (event) => {
2334
+ const typing = /^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName);
2335
+ const modalOpen = $('modalRoot').childElementCount > 0;
2336
+ if (modalOpen) return;
2337
+
2338
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
2339
+ event.preventDefault();
2340
+ if (!$('btnSave').disabled) saveConfiguration();
2341
+ } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
2342
+ event.preventDefault();
2343
+ openRepoPalette();
2344
+ } else if (event.key === '/' && !typing && !palette) {
2345
+ event.preventDefault();
2346
+ openRepoPalette();
2347
+ }
2348
+ });
1252
2349
 
1253
- if (response.ok) {
1254
- showStatus('Le processus de clonage a été lancé en arrière-plan. Vérifiez les logs pour le résultat.', 'success');
1255
- } else {
1256
- const data = await response.json().catch(() => ({ error: 'Erreur lors du clonage' }));
1257
- throw new Error(data.error || 'Erreur lors du clonage');
2350
+ window.addEventListener('beforeunload', (event) => {
2351
+ if (changedFields().length) {
2352
+ event.preventDefault();
2353
+ event.returnValue = '';
1258
2354
  }
2355
+ });
2356
+ }
2357
+
2358
+ async function boot() {
2359
+ bindChrome();
2360
+ renderStatus();
2361
+ try {
2362
+ await reloadEverything();
1259
2363
  } catch (error) {
1260
- showStatus(error.message, 'error');
1261
- cloneBtn.disabled = false;
1262
- } finally {
1263
- cloneBtn.innerHTML = originalHTML;
2364
+ toast({ type: 'error', title: 'Chargement impossible', detail: error.message, sticky: true });
1264
2365
  }
1265
- });
2366
+ checkHealth();
2367
+ setInterval(checkHealth, 15000);
2368
+ setInterval(() => { if (state.panel === 'deploy') renderActivity(); }, 30000);
2369
+ }
1266
2370
 
1267
- fetchConfig();
2371
+ boot();
2372
+ })();
1268
2373
  </script>
1269
2374
  </body>
1270
2375
  </html>