claude-code-kanban 4.14.1 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +54 -32
- package/install.js +37 -7
- package/package.json +1 -1
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +26 -25
- package/public/app.js +231 -87
- package/public/index.html +4 -4
- package/public/style.css +213 -74
- package/server.js +81 -16
package/public/style.css
CHANGED
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
--plan-dim: rgba(134, 168, 134, 0.18);
|
|
32
32
|
--mono: 'IBM Plex Mono', monospace;
|
|
33
33
|
--serif: 'Playfair Display', serif;
|
|
34
|
+
--card-gap: 7px;
|
|
35
|
+
/* the name's supporting lines read as one unit with it, so they sit tighter
|
|
36
|
+
than the gap separating the card's independent rows */
|
|
37
|
+
--card-gap-tight: 3px;
|
|
34
38
|
/* aliases: many rules reference --font-mono/--font-serif; keep them on-brand */
|
|
35
39
|
--font-mono: var(--mono);
|
|
36
40
|
--font-serif: var(--serif);
|
|
@@ -89,7 +93,7 @@ body::before {
|
|
|
89
93
|
|
|
90
94
|
/* #region SIDEBAR */
|
|
91
95
|
.sidebar {
|
|
92
|
-
width: var(--sidebar-width,
|
|
96
|
+
width: var(--sidebar-width, 310px);
|
|
93
97
|
background: var(--bg-surface);
|
|
94
98
|
border-right: 1px solid color-mix(in srgb, var(--border) 35%, transparent);
|
|
95
99
|
box-shadow: 1px 0 12px rgba(0, 0, 0, 0.04);
|
|
@@ -451,11 +455,11 @@ body::before {
|
|
|
451
455
|
.session-item {
|
|
452
456
|
display: block;
|
|
453
457
|
width: 100%;
|
|
454
|
-
padding:
|
|
455
|
-
margin-bottom:
|
|
458
|
+
padding: 13px 15px;
|
|
459
|
+
margin-bottom: 6px;
|
|
456
460
|
background: transparent;
|
|
457
461
|
border: 1px solid var(--border);
|
|
458
|
-
border-radius:
|
|
462
|
+
border-radius: 9px;
|
|
459
463
|
text-align: left;
|
|
460
464
|
cursor: pointer;
|
|
461
465
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
@@ -467,7 +471,7 @@ body::before {
|
|
|
467
471
|
}
|
|
468
472
|
|
|
469
473
|
.session-name {
|
|
470
|
-
font-size:
|
|
474
|
+
font-size: 13px;
|
|
471
475
|
color: var(--text-primary);
|
|
472
476
|
white-space: nowrap;
|
|
473
477
|
overflow: hidden;
|
|
@@ -501,7 +505,7 @@ body::before {
|
|
|
501
505
|
font-size: 12px;
|
|
502
506
|
font-weight: 450;
|
|
503
507
|
color: var(--text-tertiary);
|
|
504
|
-
margin-top:
|
|
508
|
+
margin-top: var(--card-gap-tight);
|
|
505
509
|
white-space: nowrap;
|
|
506
510
|
overflow: hidden;
|
|
507
511
|
}
|
|
@@ -509,7 +513,7 @@ body::before {
|
|
|
509
513
|
.session-branch {
|
|
510
514
|
font-size: 10px;
|
|
511
515
|
color: var(--text-secondary);
|
|
512
|
-
margin-top:
|
|
516
|
+
margin-top: var(--card-gap-tight);
|
|
513
517
|
display: block;
|
|
514
518
|
font-family: var(--mono);
|
|
515
519
|
white-space: nowrap;
|
|
@@ -520,7 +524,7 @@ body::before {
|
|
|
520
524
|
.session-plan {
|
|
521
525
|
font-size: 10px;
|
|
522
526
|
color: var(--plan);
|
|
523
|
-
margin-top:
|
|
527
|
+
margin-top: var(--card-gap-tight);
|
|
524
528
|
white-space: nowrap;
|
|
525
529
|
overflow: hidden;
|
|
526
530
|
text-overflow: ellipsis;
|
|
@@ -528,7 +532,7 @@ body::before {
|
|
|
528
532
|
|
|
529
533
|
.session-goal {
|
|
530
534
|
font-size: 10px;
|
|
531
|
-
margin-top:
|
|
535
|
+
margin-top: var(--card-gap-tight);
|
|
532
536
|
white-space: nowrap;
|
|
533
537
|
overflow: hidden;
|
|
534
538
|
text-overflow: ellipsis;
|
|
@@ -552,8 +556,9 @@ body::before {
|
|
|
552
556
|
.session-progress {
|
|
553
557
|
display: flex;
|
|
554
558
|
align-items: center;
|
|
555
|
-
gap:
|
|
556
|
-
margin-top:
|
|
559
|
+
gap: 9px;
|
|
560
|
+
margin-top: var(--card-gap);
|
|
561
|
+
line-height: 1;
|
|
557
562
|
}
|
|
558
563
|
|
|
559
564
|
.progress-bar {
|
|
@@ -579,7 +584,7 @@ body::before {
|
|
|
579
584
|
|
|
580
585
|
.context-bar {
|
|
581
586
|
display: none;
|
|
582
|
-
margin-top:
|
|
587
|
+
margin-top: var(--card-gap);
|
|
583
588
|
}
|
|
584
589
|
.has-context .context-bar {
|
|
585
590
|
display: block;
|
|
@@ -682,7 +687,7 @@ body::before {
|
|
|
682
687
|
font-size: 11px;
|
|
683
688
|
font-weight: 450;
|
|
684
689
|
color: var(--text-tertiary);
|
|
685
|
-
margin-top:
|
|
690
|
+
margin-top: var(--card-gap);
|
|
686
691
|
}
|
|
687
692
|
|
|
688
693
|
.session-latest-msg {
|
|
@@ -947,8 +952,11 @@ body::before {
|
|
|
947
952
|
background: var(--bg-hover);
|
|
948
953
|
color: var(--text-primary);
|
|
949
954
|
}
|
|
955
|
+
/* Highlighted with the row's *own* theme accent rather than the active theme's, so the selected
|
|
956
|
+
row previews the theme it names. --sw-accent comes from the theme-swatch-<id> class on the row. */
|
|
950
957
|
.theme-menu-item.on {
|
|
951
|
-
color: var(--accent-text);
|
|
958
|
+
color: var(--sw-accent, var(--accent-text));
|
|
959
|
+
background: color-mix(in srgb, var(--sw-accent, var(--accent-text)) 16%, transparent);
|
|
952
960
|
}
|
|
953
961
|
/* 3-dot palette preview: surface, accent, ink (colors from themes.css vars) */
|
|
954
962
|
.theme-swatch {
|
|
@@ -1573,7 +1581,7 @@ body::before {
|
|
|
1573
1581
|
.session-indicators {
|
|
1574
1582
|
display: flex;
|
|
1575
1583
|
align-items: center;
|
|
1576
|
-
gap:
|
|
1584
|
+
gap: 5px;
|
|
1577
1585
|
flex-shrink: 0;
|
|
1578
1586
|
}
|
|
1579
1587
|
|
|
@@ -1598,13 +1606,18 @@ body::before {
|
|
|
1598
1606
|
flex-shrink: 0;
|
|
1599
1607
|
}
|
|
1600
1608
|
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1609
|
+
/* Every clickable session-card icon shares one box so they blend into a single row;
|
|
1610
|
+
resizing the row means editing this rule, not four. Per-icon rules below override
|
|
1611
|
+
only what actually differs (color, glyph size). */
|
|
1612
|
+
.team-info-btn,
|
|
1613
|
+
.plan-indicator,
|
|
1614
|
+
.loop-badge,
|
|
1615
|
+
.workflow-badge {
|
|
1616
|
+
width: 18px;
|
|
1617
|
+
height: 18px;
|
|
1604
1618
|
display: inline-flex;
|
|
1605
1619
|
align-items: center;
|
|
1606
1620
|
justify-content: center;
|
|
1607
|
-
font-size: 11px;
|
|
1608
1621
|
background: transparent;
|
|
1609
1622
|
border: 1px solid transparent;
|
|
1610
1623
|
border-radius: 4px;
|
|
@@ -1614,76 +1627,39 @@ body::before {
|
|
|
1614
1627
|
transition: all 0.15s ease;
|
|
1615
1628
|
}
|
|
1616
1629
|
|
|
1617
|
-
.team-info-btn:hover
|
|
1630
|
+
.team-info-btn:hover,
|
|
1631
|
+
.loop-badge:hover,
|
|
1632
|
+
.workflow-badge:hover {
|
|
1618
1633
|
background: var(--bg-hover);
|
|
1619
1634
|
border-color: var(--border);
|
|
1620
1635
|
color: var(--text-secondary);
|
|
1621
1636
|
}
|
|
1622
1637
|
|
|
1638
|
+
/* The boxes are wider than their 12px glyphs, so the leading icon would sit inset
|
|
1639
|
+
from the card text above it. Cancel that inset — but only when a boxed icon leads;
|
|
1640
|
+
the pill badges and .team-badge have a different inset and must not be shifted. */
|
|
1641
|
+
.session-indicators > .team-info-btn:first-child,
|
|
1642
|
+
.session-indicators > .plan-indicator:first-child,
|
|
1643
|
+
.session-indicators > .loop-badge:first-child,
|
|
1644
|
+
.session-indicators > .workflow-badge:first-child {
|
|
1645
|
+
margin-left: -3px;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
.team-info-btn {
|
|
1649
|
+
font-size: 11px;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1623
1652
|
.plan-indicator {
|
|
1624
|
-
width: 24px;
|
|
1625
|
-
height: 24px;
|
|
1626
|
-
display: inline-flex;
|
|
1627
|
-
align-items: center;
|
|
1628
|
-
justify-content: center;
|
|
1629
1653
|
background: var(--plan-dim);
|
|
1630
|
-
border: 1px solid transparent;
|
|
1631
|
-
border-radius: 4px;
|
|
1632
1654
|
color: var(--plan);
|
|
1633
|
-
cursor: pointer;
|
|
1634
|
-
flex-shrink: 0;
|
|
1635
|
-
transition: all 0.15s ease;
|
|
1636
1655
|
}
|
|
1637
1656
|
|
|
1638
1657
|
.plan-indicator:hover {
|
|
1639
|
-
background: var(--plan-dim);
|
|
1640
1658
|
border-color: var(--plan);
|
|
1641
1659
|
}
|
|
1642
1660
|
|
|
1643
|
-
/* Match .team-info-btn so the wakeups clock blends with the other session-card icons. */
|
|
1644
|
-
.loop-badge {
|
|
1645
|
-
width: 24px;
|
|
1646
|
-
height: 24px;
|
|
1647
|
-
display: inline-flex;
|
|
1648
|
-
align-items: center;
|
|
1649
|
-
justify-content: center;
|
|
1650
|
-
background: transparent;
|
|
1651
|
-
border: 1px solid transparent;
|
|
1652
|
-
border-radius: 4px;
|
|
1653
|
-
color: var(--text-muted);
|
|
1654
|
-
cursor: pointer;
|
|
1655
|
-
flex-shrink: 0;
|
|
1656
|
-
transition: all 0.15s ease;
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
.loop-badge:hover {
|
|
1660
|
-
background: var(--bg-hover);
|
|
1661
|
-
border-color: var(--border);
|
|
1662
|
-
color: var(--text-secondary);
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
1661
|
/* #region WORKFLOW */
|
|
1666
|
-
/*
|
|
1667
|
-
.workflow-badge {
|
|
1668
|
-
width: 24px;
|
|
1669
|
-
height: 24px;
|
|
1670
|
-
display: inline-flex;
|
|
1671
|
-
align-items: center;
|
|
1672
|
-
justify-content: center;
|
|
1673
|
-
background: transparent;
|
|
1674
|
-
border: 1px solid transparent;
|
|
1675
|
-
border-radius: 4px;
|
|
1676
|
-
color: var(--text-muted);
|
|
1677
|
-
cursor: pointer;
|
|
1678
|
-
flex-shrink: 0;
|
|
1679
|
-
transition: all 0.15s ease;
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
.workflow-badge:hover {
|
|
1683
|
-
background: var(--bg-hover);
|
|
1684
|
-
border-color: var(--border);
|
|
1685
|
-
color: var(--text-secondary);
|
|
1686
|
-
}
|
|
1662
|
+
/* .workflow-badge's box is defined with the other session-card icons in TEAM_BADGE. */
|
|
1687
1663
|
|
|
1688
1664
|
.workflow-row {
|
|
1689
1665
|
border: 1px solid var(--border);
|
|
@@ -3372,6 +3348,113 @@ body::before {
|
|
|
3372
3348
|
min-width: 0;
|
|
3373
3349
|
}
|
|
3374
3350
|
|
|
3351
|
+
/* margin-left:auto pins every × to the same right edge, so they read as a column
|
|
3352
|
+
however long the names in between are. */
|
|
3353
|
+
.linked-doc-remove {
|
|
3354
|
+
margin-left: auto;
|
|
3355
|
+
align-self: center;
|
|
3356
|
+
flex-shrink: 0;
|
|
3357
|
+
width: 16px;
|
|
3358
|
+
height: 16px;
|
|
3359
|
+
display: inline-flex;
|
|
3360
|
+
align-items: center;
|
|
3361
|
+
justify-content: center;
|
|
3362
|
+
padding: 0;
|
|
3363
|
+
background: none;
|
|
3364
|
+
border: none;
|
|
3365
|
+
border-radius: 3px;
|
|
3366
|
+
color: var(--text-muted);
|
|
3367
|
+
font-family: var(--mono);
|
|
3368
|
+
font-size: 14px;
|
|
3369
|
+
line-height: 1;
|
|
3370
|
+
cursor: pointer;
|
|
3371
|
+
opacity: 0.45;
|
|
3372
|
+
transition: all 0.15s ease;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
.linked-doc-item:hover .linked-doc-remove {
|
|
3376
|
+
opacity: 1;
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
.linked-doc-remove:hover {
|
|
3380
|
+
background: var(--danger-dim);
|
|
3381
|
+
color: var(--danger);
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
/* A linked file the previewer can't render opens in the editor — the dashed
|
|
3385
|
+
underline keeps that different destination visible before the click. */
|
|
3386
|
+
.linked-doc-item.is-editor .linked-doc-link {
|
|
3387
|
+
color: var(--text-secondary);
|
|
3388
|
+
text-decoration-style: dashed;
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
.linked-docs-empty {
|
|
3392
|
+
color: var(--text-muted);
|
|
3393
|
+
font-size: 11px;
|
|
3394
|
+
font-style: italic;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
.linked-docs-add-btn {
|
|
3398
|
+
display: inline-flex;
|
|
3399
|
+
align-items: center;
|
|
3400
|
+
justify-content: center;
|
|
3401
|
+
width: 18px;
|
|
3402
|
+
height: 18px;
|
|
3403
|
+
padding: 0;
|
|
3404
|
+
line-height: 1;
|
|
3405
|
+
flex-shrink: 0;
|
|
3406
|
+
background: var(--bg-elevated);
|
|
3407
|
+
border: 1px solid var(--border);
|
|
3408
|
+
border-radius: 4px;
|
|
3409
|
+
color: var(--text-muted);
|
|
3410
|
+
font-family: var(--mono);
|
|
3411
|
+
font-size: 13px;
|
|
3412
|
+
cursor: pointer;
|
|
3413
|
+
transition: all 0.15s ease;
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
.linked-docs-add-btn:hover {
|
|
3417
|
+
border-color: var(--accent);
|
|
3418
|
+
color: var(--accent-text);
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
.linked-doc-editor-slot:not(:empty) {
|
|
3422
|
+
margin-bottom: 8px;
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
.linked-doc-input {
|
|
3426
|
+
width: 100%;
|
|
3427
|
+
padding: 6px 8px;
|
|
3428
|
+
background: var(--bg-elevated);
|
|
3429
|
+
border: 1px solid var(--accent);
|
|
3430
|
+
border-radius: 4px;
|
|
3431
|
+
color: var(--text-primary);
|
|
3432
|
+
font-family: var(--mono);
|
|
3433
|
+
font-size: 12px;
|
|
3434
|
+
box-shadow: 0 0 0 2px var(--accent-dim);
|
|
3435
|
+
outline: none;
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
.linked-doc-hint {
|
|
3439
|
+
color: var(--text-muted);
|
|
3440
|
+
font-size: 10px;
|
|
3441
|
+
margin-top: 4px;
|
|
3442
|
+
overflow: hidden;
|
|
3443
|
+
text-overflow: ellipsis;
|
|
3444
|
+
white-space: nowrap;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
/* Present but empty until a path fails to resolve. */
|
|
3448
|
+
.linked-doc-error:empty {
|
|
3449
|
+
display: none;
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
.linked-doc-error {
|
|
3453
|
+
color: var(--danger);
|
|
3454
|
+
font-size: 11px;
|
|
3455
|
+
margin-top: 4px;
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3375
3458
|
/* #endregion */
|
|
3376
3459
|
|
|
3377
3460
|
/* #region PERMISSION_PENDING */
|
|
@@ -3592,6 +3675,62 @@ body.light .msg-assistant .msg-text {
|
|
|
3592
3675
|
max-height: 90vh;
|
|
3593
3676
|
}
|
|
3594
3677
|
|
|
3678
|
+
.preview-modal-dialog .modal-body {
|
|
3679
|
+
flex: 0 1 auto;
|
|
3680
|
+
display: flex;
|
|
3681
|
+
flex-direction: column;
|
|
3682
|
+
overflow: hidden;
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
#preview-modal-body {
|
|
3686
|
+
word-break: break-word;
|
|
3687
|
+
font-size: 13px;
|
|
3688
|
+
line-height: 1.6;
|
|
3689
|
+
overflow-y: auto;
|
|
3690
|
+
flex: 0 1 auto;
|
|
3691
|
+
min-height: 0;
|
|
3692
|
+
padding-right: 8px;
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
#preview-modal-meta {
|
|
3696
|
+
margin-top: 12px;
|
|
3697
|
+
font-size: 11px;
|
|
3698
|
+
color: var(--text-muted);
|
|
3699
|
+
flex-shrink: 0;
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
/* Rendered HTML pages need far more room than a markdown doc. */
|
|
3703
|
+
.modal.preview-modal-dialog.preview-html {
|
|
3704
|
+
width: 96vw;
|
|
3705
|
+
max-width: 96vw;
|
|
3706
|
+
height: 92vh;
|
|
3707
|
+
max-height: 92vh;
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
/* Keeps the fullscreen button meaningful — the generic .fullscreen rule is smaller than this. */
|
|
3711
|
+
.modal.preview-modal-dialog.preview-html.fullscreen {
|
|
3712
|
+
width: 98vw;
|
|
3713
|
+
max-width: 98vw;
|
|
3714
|
+
height: 98vh;
|
|
3715
|
+
max-height: 98vh;
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
.preview-html .modal-body,
|
|
3719
|
+
.preview-html #preview-modal-body {
|
|
3720
|
+
flex: 1 1 auto;
|
|
3721
|
+
overflow: hidden;
|
|
3722
|
+
padding-right: 0;
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
.preview-html-frame {
|
|
3726
|
+
width: 100%;
|
|
3727
|
+
height: 100%;
|
|
3728
|
+
border: 0;
|
|
3729
|
+
border-radius: 6px;
|
|
3730
|
+
/* Pages authored for a light page default would be unreadable on the dark theme. */
|
|
3731
|
+
background: #fff;
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3595
3734
|
.preview-fm {
|
|
3596
3735
|
margin: 0 0 14px;
|
|
3597
3736
|
border: 1px solid var(--border);
|
package/server.js
CHANGED
|
@@ -32,7 +32,8 @@ const {
|
|
|
32
32
|
|
|
33
33
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
34
34
|
const { runInstall, runUninstall } = require("./install");
|
|
35
|
-
|
|
35
|
+
const pluginOnly = process.argv.includes("--plugin-only");
|
|
36
|
+
(process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
|
|
36
37
|
.then(() => process.exit(0))
|
|
37
38
|
.catch(e => { console.error(e.message); process.exit(1); });
|
|
38
39
|
return;
|
|
@@ -2612,23 +2613,52 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
|
|
|
2612
2613
|
}
|
|
2613
2614
|
});
|
|
2614
2615
|
|
|
2615
|
-
// API:
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2616
|
+
// API: File preview — read file and broadcast to clients
|
|
2617
|
+
const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
|
|
2618
|
+
// Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
|
|
2619
|
+
const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
|
|
2620
|
+
|
|
2621
|
+
function previewError(status, message) {
|
|
2622
|
+
const err = new Error(message);
|
|
2623
|
+
err.status = status;
|
|
2624
|
+
return err;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
// Existence + kind of a file target, with the HTTP status codes both the preview and
|
|
2628
|
+
// the link-a-file endpoints report. `kind` is null for anything the previewer can't
|
|
2629
|
+
// render — the caller decides whether that disqualifies the path.
|
|
2630
|
+
async function statFileTarget(absPath) {
|
|
2623
2631
|
try {
|
|
2624
|
-
|
|
2632
|
+
const stats = await fs.stat(absPath);
|
|
2633
|
+
if (!stats.isFile()) throw previewError(400, 'Not a file');
|
|
2634
|
+
return { size: stats.size, kind: PREVIEW_KINDS[path.extname(absPath).toLowerCase()] || null };
|
|
2625
2635
|
} catch (e) {
|
|
2626
|
-
if (e.
|
|
2627
|
-
if (e.code === '
|
|
2636
|
+
if (e.status) throw e;
|
|
2637
|
+
if (e.code === 'ENOENT') throw previewError(404, 'File not found');
|
|
2638
|
+
if (e.code === 'EISDIR') throw previewError(400, 'Not a file');
|
|
2628
2639
|
throw e;
|
|
2629
2640
|
}
|
|
2630
2641
|
}
|
|
2631
2642
|
|
|
2643
|
+
// Checks the file is previewable without reading it — the broadcast path needs the
|
|
2644
|
+
// validation (and its status codes) but never the content.
|
|
2645
|
+
async function validatePreviewFile(absPath) {
|
|
2646
|
+
const { kind, size } = await statFileTarget(absPath);
|
|
2647
|
+
if (!kind) throw previewError(400, 'Only .md/.markdown/.html/.htm files are allowed');
|
|
2648
|
+
if (size > PREVIEW_MAX_BYTES) {
|
|
2649
|
+
throw previewError(
|
|
2650
|
+
400,
|
|
2651
|
+
`Preview too large (${Math.round(size / 1048576)}MB, max ${PREVIEW_MAX_BYTES / 1048576}MB)`
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
return { kind, size };
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
async function readPreviewFile(absPath) {
|
|
2658
|
+
const { kind } = await validatePreviewFile(absPath);
|
|
2659
|
+
return { content: await fs.readFile(absPath, 'utf8'), kind };
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2632
2662
|
function resolvePreviewPath(filePath, base) {
|
|
2633
2663
|
if (!filePath || typeof filePath !== 'string') return null;
|
|
2634
2664
|
if (path.isAbsolute(filePath)) return filePath;
|
|
@@ -2650,8 +2680,10 @@ app.post('/api/preview', async (req, res) => {
|
|
|
2650
2680
|
const { path: filePath, sessionId, base } = req.body || {};
|
|
2651
2681
|
const abs = resolvePreviewPath(filePath, base);
|
|
2652
2682
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
2653
|
-
|
|
2654
|
-
|
|
2683
|
+
// Validate here so the CLI still gets 400/404, but broadcast the path only —
|
|
2684
|
+
// each tab fetches the document itself instead of it being fanned out over SSE.
|
|
2685
|
+
await validatePreviewFile(abs);
|
|
2686
|
+
broadcast({ type: 'preview:open', path: abs, sessionId: sessionId || null });
|
|
2655
2687
|
res.json({ success: true });
|
|
2656
2688
|
} catch (error) {
|
|
2657
2689
|
console.error('Error in /api/preview:', error);
|
|
@@ -2659,6 +2691,24 @@ app.post('/api/preview', async (req, res) => {
|
|
|
2659
2691
|
}
|
|
2660
2692
|
});
|
|
2661
2693
|
|
|
2694
|
+
// API: Link a file to a session's sidebar docs without opening the preview modal.
|
|
2695
|
+
// Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
|
|
2696
|
+
// opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
|
|
2697
|
+
app.post('/api/document/link', async (req, res) => {
|
|
2698
|
+
try {
|
|
2699
|
+
const { path: filePath, sessionId, unlink } = req.body || {};
|
|
2700
|
+
if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
|
|
2701
|
+
const abs = resolvePreviewPath(filePath);
|
|
2702
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
2703
|
+
if (!unlink) await statFileTarget(abs);
|
|
2704
|
+
broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink });
|
|
2705
|
+
res.json({ success: true });
|
|
2706
|
+
} catch (error) {
|
|
2707
|
+
console.error('Error in /api/document/link:', error);
|
|
2708
|
+
res.status(error.status || 500).json({ error: error.message || 'Link failed' });
|
|
2709
|
+
}
|
|
2710
|
+
});
|
|
2711
|
+
|
|
2662
2712
|
app.get('/api/session/resolve', (req, res) => {
|
|
2663
2713
|
try {
|
|
2664
2714
|
const idArg = (req.query.id || '').toString();
|
|
@@ -2731,14 +2781,29 @@ app.get('/api/preview', async (req, res) => {
|
|
|
2731
2781
|
try {
|
|
2732
2782
|
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
2733
2783
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
2734
|
-
const content = await
|
|
2735
|
-
res.json({ path: abs, content });
|
|
2784
|
+
const { content, kind } = await readPreviewFile(abs);
|
|
2785
|
+
res.json({ path: abs, content, kind });
|
|
2736
2786
|
} catch (error) {
|
|
2737
2787
|
console.error('Error in GET /api/preview:', error);
|
|
2738
2788
|
res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
|
|
2739
2789
|
}
|
|
2740
2790
|
});
|
|
2741
2791
|
|
|
2792
|
+
// API: Resolve a hand-typed path to an absolute one that exists. Deliberately not
|
|
2793
|
+
// extension-restricted — a linked file the previewer can't render (kind: null) is
|
|
2794
|
+
// still linkable, the client just opens it in the editor instead.
|
|
2795
|
+
app.get('/api/file/resolve', async (req, res) => {
|
|
2796
|
+
try {
|
|
2797
|
+
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
2798
|
+
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
2799
|
+
// No size cap here — an unpreviewable file of any size is still linkable.
|
|
2800
|
+
res.json({ path: abs, ...(await statFileTarget(abs)) });
|
|
2801
|
+
} catch (error) {
|
|
2802
|
+
console.error('Error in GET /api/file/resolve:', error);
|
|
2803
|
+
res.status(error.status || 500).json({ error: error.message || 'Failed to resolve file' });
|
|
2804
|
+
}
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2742
2807
|
// SSE endpoint for live updates
|
|
2743
2808
|
app.get('/api/events', (req, res) => {
|
|
2744
2809
|
res.setHeader('Content-Type', 'text/event-stream');
|