coplan-engine 0.1.3 → 0.4.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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/coplan/application.css +268 -85
- data/app/controllers/coplan/api/v1/comments_controller.rb +7 -7
- data/app/controllers/coplan/api/v1/operations_controller.rb +38 -0
- data/app/controllers/coplan/comment_threads_controller.rb +26 -72
- data/app/controllers/coplan/plans_controller.rb +1 -3
- data/app/helpers/coplan/application_helper.rb +44 -0
- data/app/helpers/coplan/markdown_helper.rb +1 -0
- data/app/javascript/controllers/coplan/comment_form_controller.js +14 -0
- data/app/javascript/controllers/coplan/comment_nav_controller.js +247 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +143 -169
- data/app/jobs/coplan/automated_review_job.rb +4 -4
- data/app/models/coplan/comment_thread.rb +13 -7
- data/app/policies/coplan/comment_thread_policy.rb +1 -1
- data/app/services/coplan/plans/apply_operations.rb +43 -0
- data/app/services/coplan/plans/commit_session.rb +26 -1
- data/app/services/coplan/plans/position_resolver.rb +111 -0
- data/app/views/coplan/comment_threads/_new_comment_form.html.erb +2 -1
- data/app/views/coplan/comment_threads/_reply_form.html.erb +2 -1
- data/app/views/coplan/comment_threads/_thread.html.erb +3 -6
- data/app/views/coplan/comment_threads/_thread_popover.html.erb +58 -0
- data/app/views/coplan/plans/show.html.erb +22 -30
- data/app/views/layouts/coplan/application.html.erb +5 -0
- data/config/routes.rb +2 -2
- data/db/migrate/20260320145453_migrate_comment_thread_statuses.rb +31 -0
- data/lib/coplan/version.rb +1 -1
- metadata +5 -2
- data/app/javascript/controllers/coplan/tabs_controller.js +0 -18
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc0c571fae079ecc474da55dea08af61a198ca761f6f3c9b5c3b672afe3b838c
|
|
4
|
+
data.tar.gz: 791565182e62dab38561ae29eeb9794cf0f0087222449b4ef5842525b5e28424
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 973c44900f288a2ba876c9ee2df96e47e13668f271c3b5e1de187ddf4d935b4fef63de2dac2753cf1acc82628a4a4e3987c8e8e3ea8cd3e38a9ecd123e7b734e
|
|
7
|
+
data.tar.gz: e7866646881f7c217cb5b23e0adac89651e6fa47facfe9f4f33049a4e2a7362f8eb209f40dc161dbd43bdd689e643248c050be0044821efeae6abcb97c13aa08
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
--space-2xl: 3rem;
|
|
32
32
|
|
|
33
33
|
/* Typography */
|
|
34
|
-
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
34
|
+
--font-sans: 'Lexend', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
35
35
|
--font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace;
|
|
36
36
|
--text-sm: 0.875rem;
|
|
37
37
|
--text-base: 1rem;
|
|
@@ -119,6 +119,17 @@ img, svg {
|
|
|
119
119
|
border-radius: 6px;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
.env-badge {
|
|
123
|
+
font-size: 10px;
|
|
124
|
+
font-weight: 600;
|
|
125
|
+
color: #fff;
|
|
126
|
+
padding: 1px 6px;
|
|
127
|
+
border-radius: 4px;
|
|
128
|
+
text-transform: uppercase;
|
|
129
|
+
letter-spacing: 0.5px;
|
|
130
|
+
line-height: 1.4;
|
|
131
|
+
}
|
|
132
|
+
|
|
122
133
|
.site-nav__brand:hover {
|
|
123
134
|
text-decoration: none;
|
|
124
135
|
color: var(--color-primary);
|
|
@@ -268,6 +279,10 @@ img, svg {
|
|
|
268
279
|
.badge--developing { background: #dbeafe; color: var(--color-status-developing); }
|
|
269
280
|
.badge--live { background: #d1fae5; color: var(--color-status-live); }
|
|
270
281
|
.badge--abandoned { background: #f3f4f6; color: var(--color-status-abandoned); }
|
|
282
|
+
.badge--pending { background: #fef3c7; color: var(--color-status-considering); }
|
|
283
|
+
.badge--todo { background: #dbeafe; color: var(--color-status-developing); }
|
|
284
|
+
.badge--discarded { background: #f3f4f6; color: var(--color-status-abandoned); }
|
|
285
|
+
.badge--resolved { background: #d1fae5; color: var(--color-status-live); }
|
|
271
286
|
|
|
272
287
|
/* Forms */
|
|
273
288
|
.form-group {
|
|
@@ -514,7 +529,7 @@ img, svg {
|
|
|
514
529
|
|
|
515
530
|
.markdown-rendered p {
|
|
516
531
|
margin-bottom: var(--space-md);
|
|
517
|
-
line-height: 1.
|
|
532
|
+
line-height: 1.9;
|
|
518
533
|
}
|
|
519
534
|
|
|
520
535
|
.markdown-rendered ul,
|
|
@@ -525,7 +540,7 @@ img, svg {
|
|
|
525
540
|
|
|
526
541
|
.markdown-rendered li {
|
|
527
542
|
margin-bottom: var(--space-xs);
|
|
528
|
-
line-height: 1.
|
|
543
|
+
line-height: 1.9;
|
|
529
544
|
}
|
|
530
545
|
|
|
531
546
|
.markdown-rendered pre {
|
|
@@ -605,20 +620,55 @@ img, svg {
|
|
|
605
620
|
|
|
606
621
|
/* Anchor text highlights */
|
|
607
622
|
.anchor-highlight {
|
|
608
|
-
background: rgba(255, 213, 79, 0.3);
|
|
609
|
-
border-bottom: 2px solid rgba(255, 179, 0, 0.5);
|
|
610
623
|
cursor: pointer;
|
|
611
624
|
transition: background 0.2s;
|
|
612
625
|
}
|
|
613
626
|
|
|
614
|
-
.anchor-highlight
|
|
615
|
-
background: rgba(
|
|
627
|
+
.anchor-highlight--open {
|
|
628
|
+
background: rgba(108, 140, 255, 0.12);
|
|
629
|
+
border-bottom: 2px solid rgba(108, 140, 255, 0.6);
|
|
630
|
+
cursor: pointer;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
.anchor-highlight--open:hover {
|
|
634
|
+
background: rgba(108, 140, 255, 0.22);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
.anchor-highlight--pending {
|
|
638
|
+
background: rgba(245, 158, 11, 0.12);
|
|
639
|
+
border-bottom: 2px solid rgba(245, 158, 11, 0.6);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
.anchor-highlight--pending:hover {
|
|
643
|
+
background: rgba(245, 158, 11, 0.22);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.anchor-highlight--todo {
|
|
647
|
+
background: rgba(59, 130, 246, 0.12);
|
|
648
|
+
border-bottom: 2px solid rgba(59, 130, 246, 0.6);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.anchor-highlight--todo:hover {
|
|
652
|
+
background: rgba(59, 130, 246, 0.22);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.anchor-highlight--resolved {
|
|
656
|
+
background: none;
|
|
657
|
+
border-bottom: none;
|
|
658
|
+
cursor: default;
|
|
659
|
+
pointer-events: none;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.plan-layout--show-resolved .anchor-highlight--resolved {
|
|
663
|
+
border-bottom: 1px dashed var(--color-text-muted);
|
|
664
|
+
cursor: pointer;
|
|
665
|
+
pointer-events: auto;
|
|
616
666
|
}
|
|
617
667
|
|
|
618
668
|
.anchor-highlight--active {
|
|
619
|
-
background: rgba(
|
|
620
|
-
border-bottom: 2px solid var(--color-
|
|
621
|
-
outline: 2px solid rgba(
|
|
669
|
+
background: rgba(108, 140, 255, 0.25);
|
|
670
|
+
border-bottom: 2px solid var(--color-primary);
|
|
671
|
+
outline: 2px solid rgba(108, 140, 255, 0.3);
|
|
622
672
|
outline-offset: 1px;
|
|
623
673
|
}
|
|
624
674
|
|
|
@@ -653,87 +703,35 @@ img, svg {
|
|
|
653
703
|
border-radius: 0 var(--radius) var(--radius) 0;
|
|
654
704
|
}
|
|
655
705
|
|
|
656
|
-
/* Plan layout
|
|
706
|
+
/* Plan layout */
|
|
657
707
|
.plan-layout {
|
|
658
708
|
position: relative;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
.plan-layout__content {
|
|
662
|
-
max-width: 60%;
|
|
663
|
-
position: relative;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
.plan-layout__sidebar {
|
|
667
|
-
position: absolute;
|
|
668
|
-
top: 0;
|
|
669
|
-
right: 0;
|
|
670
|
-
width: 22rem;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
@media (max-width: 900px) {
|
|
674
|
-
.plan-layout__content {
|
|
675
|
-
max-width: 100%;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
.plan-layout__sidebar {
|
|
679
|
-
position: static;
|
|
680
|
-
width: 100%;
|
|
681
|
-
margin-top: var(--space-lg);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
.sidebar-heading {
|
|
685
|
-
font-size: var(--text-lg);
|
|
686
|
-
font-weight: 700;
|
|
687
|
-
margin-bottom: var(--space-md);
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/* Comment tabs */
|
|
691
|
-
.comment-tabs__nav {
|
|
692
709
|
display: flex;
|
|
693
710
|
gap: 0;
|
|
694
|
-
border-bottom: 2px solid var(--color-border);
|
|
695
|
-
margin-bottom: var(--space-md);
|
|
696
711
|
}
|
|
697
712
|
|
|
698
|
-
.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
color: var(--color-text-muted);
|
|
703
|
-
background: none;
|
|
704
|
-
border: none;
|
|
705
|
-
border-bottom: 2px solid transparent;
|
|
706
|
-
margin-bottom: -2px;
|
|
707
|
-
cursor: pointer;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
.comment-tab:hover {
|
|
711
|
-
color: var(--color-text);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
.comment-tab--active {
|
|
715
|
-
color: var(--color-primary);
|
|
716
|
-
border-bottom-color: var(--color-primary);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
.comment-tab__count {
|
|
720
|
-
background: var(--color-border);
|
|
721
|
-
color: var(--color-text-muted);
|
|
722
|
-
font-size: 0.7rem;
|
|
723
|
-
padding: 1px 6px;
|
|
724
|
-
border-radius: 10px;
|
|
725
|
-
margin-left: 4px;
|
|
713
|
+
.plan-layout__margin {
|
|
714
|
+
width: 40px;
|
|
715
|
+
flex-shrink: 0;
|
|
716
|
+
position: relative;
|
|
726
717
|
}
|
|
727
718
|
|
|
728
|
-
.
|
|
729
|
-
|
|
730
|
-
|
|
719
|
+
.plan-layout__content {
|
|
720
|
+
position: relative;
|
|
721
|
+
flex: 1;
|
|
722
|
+
min-width: 0;
|
|
731
723
|
}
|
|
732
724
|
|
|
733
725
|
/* Comment form */
|
|
734
726
|
.comment-form {
|
|
735
|
-
|
|
727
|
+
position: absolute;
|
|
728
|
+
z-index: 100;
|
|
729
|
+
width: 360px;
|
|
736
730
|
padding: var(--space-md);
|
|
731
|
+
background: var(--color-surface);
|
|
732
|
+
border: 1px solid var(--color-border);
|
|
733
|
+
border-radius: var(--radius);
|
|
734
|
+
box-shadow: var(--shadow-lg);
|
|
737
735
|
}
|
|
738
736
|
|
|
739
737
|
.comment-form .form-group {
|
|
@@ -757,13 +755,6 @@ img, svg {
|
|
|
757
755
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
|
758
756
|
}
|
|
759
757
|
|
|
760
|
-
/* Comment threads list */
|
|
761
|
-
.comment-threads-list {
|
|
762
|
-
display: flex;
|
|
763
|
-
flex-direction: column;
|
|
764
|
-
gap: var(--space-md);
|
|
765
|
-
}
|
|
766
|
-
|
|
767
758
|
/* Comment thread */
|
|
768
759
|
.comment-thread {
|
|
769
760
|
padding: var(--space-md);
|
|
@@ -960,3 +951,195 @@ img, svg {
|
|
|
960
951
|
color: var(--color-text-muted);
|
|
961
952
|
margin-top: var(--space-xs);
|
|
962
953
|
}
|
|
954
|
+
|
|
955
|
+
/* Thread popover data containers (hidden, provide data for JS) */
|
|
956
|
+
.thread-popover-data {
|
|
957
|
+
display: contents;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/* Thread popover (native HTML Popover API) */
|
|
961
|
+
.thread-popover {
|
|
962
|
+
position: fixed;
|
|
963
|
+
margin: 0;
|
|
964
|
+
padding: var(--space-md);
|
|
965
|
+
background: var(--color-surface);
|
|
966
|
+
border: 1px solid var(--color-border);
|
|
967
|
+
border-radius: var(--radius);
|
|
968
|
+
box-shadow: var(--shadow-lg);
|
|
969
|
+
width: 380px;
|
|
970
|
+
max-height: 60vh;
|
|
971
|
+
overflow-y: auto;
|
|
972
|
+
z-index: 200;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.thread-popover::backdrop {
|
|
976
|
+
background: transparent;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.thread-popover__header {
|
|
980
|
+
display: flex;
|
|
981
|
+
gap: var(--space-xs);
|
|
982
|
+
align-items: center;
|
|
983
|
+
margin-bottom: var(--space-sm);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
.thread-popover__quote {
|
|
987
|
+
border-left: 3px solid rgba(108, 140, 255, 0.6);
|
|
988
|
+
padding: var(--space-xs) var(--space-sm);
|
|
989
|
+
margin: 0 0 var(--space-sm) 0;
|
|
990
|
+
font-size: var(--text-sm);
|
|
991
|
+
color: var(--color-text-muted);
|
|
992
|
+
background: rgba(108, 140, 255, 0.05);
|
|
993
|
+
border-radius: 0 var(--radius) var(--radius) 0;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
.thread-popover__comments {
|
|
997
|
+
display: flex;
|
|
998
|
+
flex-direction: column;
|
|
999
|
+
gap: var(--space-xs);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
.thread-popover__reply {
|
|
1003
|
+
margin-top: var(--space-sm);
|
|
1004
|
+
padding-top: var(--space-sm);
|
|
1005
|
+
border-top: 1px solid var(--color-border);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
.thread-popover__reply textarea {
|
|
1009
|
+
width: 100%;
|
|
1010
|
+
padding: var(--space-sm);
|
|
1011
|
+
font-family: var(--font-sans);
|
|
1012
|
+
font-size: var(--text-sm);
|
|
1013
|
+
border: 1px solid var(--color-border);
|
|
1014
|
+
border-radius: var(--radius);
|
|
1015
|
+
resize: vertical;
|
|
1016
|
+
min-height: 3rem;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
.thread-popover__reply textarea:focus {
|
|
1020
|
+
outline: none;
|
|
1021
|
+
border-color: var(--color-primary);
|
|
1022
|
+
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
.thread-popover__reply .form-group {
|
|
1026
|
+
margin-bottom: var(--space-xs);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
.thread-popover__actions {
|
|
1030
|
+
display: flex;
|
|
1031
|
+
gap: var(--space-xs);
|
|
1032
|
+
margin-top: var(--space-sm);
|
|
1033
|
+
padding-top: var(--space-sm);
|
|
1034
|
+
border-top: 1px solid var(--color-border);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/* Margin dots */
|
|
1038
|
+
.margin-dot {
|
|
1039
|
+
position: absolute;
|
|
1040
|
+
width: 10px;
|
|
1041
|
+
height: 10px;
|
|
1042
|
+
border-radius: 50%;
|
|
1043
|
+
border: 2px solid transparent;
|
|
1044
|
+
background-clip: padding-box;
|
|
1045
|
+
cursor: pointer;
|
|
1046
|
+
left: 14px;
|
|
1047
|
+
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
|
1048
|
+
display: flex;
|
|
1049
|
+
align-items: center;
|
|
1050
|
+
justify-content: center;
|
|
1051
|
+
padding: 0;
|
|
1052
|
+
outline: none;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
.margin-dot::after {
|
|
1056
|
+
content: "";
|
|
1057
|
+
position: absolute;
|
|
1058
|
+
inset: -8px;
|
|
1059
|
+
border-radius: 50%;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
.margin-dot:hover {
|
|
1063
|
+
transform: scale(1.4);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
.margin-dot:focus-visible {
|
|
1067
|
+
outline: 2px solid var(--color-primary);
|
|
1068
|
+
outline-offset: 2px;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
.margin-dot--pending {
|
|
1072
|
+
background: var(--color-status-considering);
|
|
1073
|
+
border-color: rgba(245, 158, 11, 0.3);
|
|
1074
|
+
box-shadow: 0 0 6px rgba(245, 158, 11, 0.25);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
.margin-dot--todo {
|
|
1078
|
+
background: var(--color-status-developing);
|
|
1079
|
+
border-color: rgba(59, 130, 246, 0.3);
|
|
1080
|
+
box-shadow: 0 0 6px rgba(59, 130, 246, 0.25);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
.margin-dot--resolved {
|
|
1084
|
+
background: var(--color-text-muted);
|
|
1085
|
+
opacity: 0.4;
|
|
1086
|
+
display: none;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
.plan-layout--show-resolved .margin-dot--resolved {
|
|
1090
|
+
display: flex;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/* Ensure content doesn't get hidden behind fixed toolbar */
|
|
1094
|
+
body:has(.comment-toolbar) .main-content {
|
|
1095
|
+
padding-bottom: 3.5rem;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/* Comment toolbar (bottom-pinned) */
|
|
1099
|
+
.comment-toolbar {
|
|
1100
|
+
position: fixed;
|
|
1101
|
+
bottom: 0;
|
|
1102
|
+
left: 0;
|
|
1103
|
+
right: 0;
|
|
1104
|
+
background: var(--color-surface);
|
|
1105
|
+
border-top: 1px solid var(--color-border);
|
|
1106
|
+
padding: var(--space-sm) var(--space-lg);
|
|
1107
|
+
display: flex;
|
|
1108
|
+
align-items: center;
|
|
1109
|
+
gap: var(--space-lg);
|
|
1110
|
+
z-index: 50;
|
|
1111
|
+
justify-content: center;
|
|
1112
|
+
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
.comment-toolbar__count {
|
|
1116
|
+
font-size: var(--text-sm);
|
|
1117
|
+
font-weight: 600;
|
|
1118
|
+
color: var(--color-text);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
.comment-toolbar__nav {
|
|
1122
|
+
display: flex;
|
|
1123
|
+
align-items: center;
|
|
1124
|
+
gap: var(--space-xs);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
.comment-toolbar__position {
|
|
1128
|
+
font-size: var(--text-sm);
|
|
1129
|
+
color: var(--color-text-muted);
|
|
1130
|
+
min-width: 4rem;
|
|
1131
|
+
text-align: center;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
.comment-toolbar__toggle {
|
|
1135
|
+
font-size: var(--text-sm);
|
|
1136
|
+
color: var(--color-text-muted);
|
|
1137
|
+
cursor: pointer;
|
|
1138
|
+
display: flex;
|
|
1139
|
+
align-items: center;
|
|
1140
|
+
gap: var(--space-xs);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
.comment-toolbar__toggle input[type="checkbox"] {
|
|
1144
|
+
cursor: pointer;
|
|
1145
|
+
}
|
|
@@ -56,7 +56,7 @@ module CoPlan
|
|
|
56
56
|
render json: { thread_id: thread.id, status: thread.status }
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
def
|
|
59
|
+
def discard
|
|
60
60
|
thread = @plan.comment_threads.find_by(id: params[:id])
|
|
61
61
|
unless thread
|
|
62
62
|
render json: { error: "Comment thread not found" }, status: :not_found
|
|
@@ -64,12 +64,12 @@ module CoPlan
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
policy = CommentThreadPolicy.new(current_user, thread)
|
|
67
|
-
unless policy.
|
|
67
|
+
unless policy.discard?
|
|
68
68
|
render json: { error: "Not authorized" }, status: :forbidden
|
|
69
69
|
return
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
thread.
|
|
72
|
+
thread.discard!(current_user)
|
|
73
73
|
broadcast_thread_update(thread)
|
|
74
74
|
|
|
75
75
|
render json: { thread_id: thread.id, status: thread.status }
|
|
@@ -104,10 +104,10 @@ module CoPlan
|
|
|
104
104
|
private
|
|
105
105
|
|
|
106
106
|
def broadcast_new_thread(thread)
|
|
107
|
-
Broadcaster.
|
|
107
|
+
Broadcaster.append_to(
|
|
108
108
|
@plan,
|
|
109
|
-
target: "
|
|
110
|
-
partial: "coplan/comment_threads/
|
|
109
|
+
target: "plan-threads",
|
|
110
|
+
partial: "coplan/comment_threads/thread_popover",
|
|
111
111
|
locals: { thread: thread, plan: @plan }
|
|
112
112
|
)
|
|
113
113
|
end
|
|
@@ -116,7 +116,7 @@ module CoPlan
|
|
|
116
116
|
Broadcaster.replace_to(
|
|
117
117
|
@plan,
|
|
118
118
|
target: ActionView::RecordIdentifier.dom_id(thread),
|
|
119
|
-
partial: "coplan/comment_threads/
|
|
119
|
+
partial: "coplan/comment_threads/thread_popover",
|
|
120
120
|
locals: { thread: thread, plan: @plan }
|
|
121
121
|
)
|
|
122
122
|
end
|
|
@@ -281,6 +281,44 @@ module CoPlan
|
|
|
281
281
|
return
|
|
282
282
|
end
|
|
283
283
|
end
|
|
284
|
+
when "replace_section"
|
|
285
|
+
return unless op["heading"]
|
|
286
|
+
include_heading = op.fetch("include_heading", true)
|
|
287
|
+
include_heading = include_heading != false && include_heading != "false"
|
|
288
|
+
|
|
289
|
+
transformed_ranges.each do |tr|
|
|
290
|
+
if include_heading
|
|
291
|
+
# Verify the heading is the first line of the section range
|
|
292
|
+
first_line_end = content.index("\n", tr[0]) || tr[1]
|
|
293
|
+
first_line = content[tr[0]...[first_line_end, tr[1]].min]
|
|
294
|
+
unless first_line&.rstrip == op["heading"]&.rstrip
|
|
295
|
+
render json: {
|
|
296
|
+
error: "Conflict: section at target position has changed",
|
|
297
|
+
current_revision: @plan.current_revision,
|
|
298
|
+
expected_heading: op["heading"],
|
|
299
|
+
found: content[tr[0]...tr[1]]&.slice(0, 200)
|
|
300
|
+
}, status: :conflict
|
|
301
|
+
return
|
|
302
|
+
end
|
|
303
|
+
else
|
|
304
|
+
# Body-only: verify the heading appears on the line before tr[0].
|
|
305
|
+
# Walk backwards past any blank lines to find the heading text.
|
|
306
|
+
search_pos = tr[0]
|
|
307
|
+
search_pos -= 1 while search_pos > 0 && content[search_pos - 1] == "\n"
|
|
308
|
+
heading_line_end = search_pos
|
|
309
|
+
heading_line_start = search_pos > 0 ? (content.rindex("\n", search_pos - 1) || -1) + 1 : 0
|
|
310
|
+
heading_text = content[heading_line_start...heading_line_end]
|
|
311
|
+
unless heading_text == op["heading"]
|
|
312
|
+
render json: {
|
|
313
|
+
error: "Conflict: section heading before target position has changed",
|
|
314
|
+
current_revision: @plan.current_revision,
|
|
315
|
+
expected_heading: op["heading"],
|
|
316
|
+
found: heading_text
|
|
317
|
+
}, status: :conflict
|
|
318
|
+
return
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
284
322
|
end
|
|
285
323
|
end
|
|
286
324
|
|
|
@@ -3,11 +3,15 @@ module CoPlan
|
|
|
3
3
|
include ActionView::RecordIdentifier
|
|
4
4
|
|
|
5
5
|
before_action :set_plan
|
|
6
|
-
before_action :set_thread, only: [:resolve, :accept, :
|
|
6
|
+
before_action :set_thread, only: [:resolve, :accept, :discard, :reopen]
|
|
7
7
|
|
|
8
8
|
def create
|
|
9
9
|
authorize!(@plan, :show?)
|
|
10
10
|
|
|
11
|
+
# Author's own comments start as "todo" (self-assigned work item);
|
|
12
|
+
# non-author comments start as "pending" (awaiting author triage).
|
|
13
|
+
initial_status = current_user.id == @plan.created_by_user_id ? "todo" : "pending"
|
|
14
|
+
|
|
11
15
|
thread = @plan.comment_threads.new(
|
|
12
16
|
plan_version: @plan.current_plan_version,
|
|
13
17
|
anchor_text: params[:comment_thread][:anchor_text].presence,
|
|
@@ -15,7 +19,8 @@ module CoPlan
|
|
|
15
19
|
anchor_occurrence: params[:comment_thread][:anchor_occurrence].presence&.to_i,
|
|
16
20
|
start_line: params[:comment_thread][:start_line].presence,
|
|
17
21
|
end_line: params[:comment_thread][:end_line].presence,
|
|
18
|
-
created_by_user: current_user
|
|
22
|
+
created_by_user: current_user,
|
|
23
|
+
status: initial_status
|
|
19
24
|
)
|
|
20
25
|
|
|
21
26
|
thread.save!
|
|
@@ -26,8 +31,14 @@ module CoPlan
|
|
|
26
31
|
body_markdown: params[:comment_thread][:body_markdown]
|
|
27
32
|
)
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
if thread.anchored?
|
|
35
|
+
Broadcaster.append_to(
|
|
36
|
+
@plan,
|
|
37
|
+
target: "plan-threads",
|
|
38
|
+
partial: "coplan/comment_threads/thread_popover",
|
|
39
|
+
locals: { thread: thread, plan: @plan }
|
|
40
|
+
)
|
|
41
|
+
end
|
|
31
42
|
|
|
32
43
|
respond_with_stream_or_redirect("Comment added.")
|
|
33
44
|
end
|
|
@@ -35,34 +46,28 @@ module CoPlan
|
|
|
35
46
|
def resolve
|
|
36
47
|
authorize!(@thread, :resolve?)
|
|
37
48
|
@thread.resolve!(current_user)
|
|
38
|
-
|
|
49
|
+
broadcast_thread_replace(@thread)
|
|
39
50
|
respond_with_stream_or_redirect("Thread resolved.")
|
|
40
51
|
end
|
|
41
52
|
|
|
42
53
|
def accept
|
|
43
54
|
authorize!(@thread, :accept?)
|
|
44
55
|
@thread.accept!(current_user)
|
|
45
|
-
|
|
56
|
+
broadcast_thread_replace(@thread)
|
|
46
57
|
respond_with_stream_or_redirect("Thread accepted.")
|
|
47
58
|
end
|
|
48
59
|
|
|
49
|
-
def
|
|
50
|
-
authorize!(@thread, :
|
|
51
|
-
@thread.
|
|
52
|
-
|
|
53
|
-
respond_with_stream_or_redirect("Thread
|
|
60
|
+
def discard
|
|
61
|
+
authorize!(@thread, :discard?)
|
|
62
|
+
@thread.discard!(current_user)
|
|
63
|
+
broadcast_thread_replace(@thread)
|
|
64
|
+
respond_with_stream_or_redirect("Thread discarded.")
|
|
54
65
|
end
|
|
55
66
|
|
|
56
67
|
def reopen
|
|
57
68
|
authorize!(@thread, :reopen?)
|
|
58
|
-
@thread.update!(status: "
|
|
59
|
-
|
|
60
|
-
# since the active scope excludes out_of_date rows.
|
|
61
|
-
if @thread.out_of_date?
|
|
62
|
-
broadcast_thread_replace(@thread)
|
|
63
|
-
else
|
|
64
|
-
broadcast_thread_move(@thread, from: "resolved-comment-threads", to: "comment-threads")
|
|
65
|
-
end
|
|
69
|
+
@thread.update!(status: "pending", resolved_by_user: nil)
|
|
70
|
+
broadcast_thread_replace(@thread)
|
|
66
71
|
respond_with_stream_or_redirect("Thread reopened.")
|
|
67
72
|
end
|
|
68
73
|
|
|
@@ -76,15 +81,6 @@ module CoPlan
|
|
|
76
81
|
@thread = @plan.comment_threads.find(params[:id])
|
|
77
82
|
end
|
|
78
83
|
|
|
79
|
-
def broadcast_new_thread(thread)
|
|
80
|
-
Broadcaster.prepend_to(
|
|
81
|
-
@plan,
|
|
82
|
-
target: "comment-threads",
|
|
83
|
-
partial: "coplan/comment_threads/thread",
|
|
84
|
-
locals: { thread: thread, plan: @plan }
|
|
85
|
-
)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
84
|
# Broadcasts update all clients (including the submitter) via WebSocket.
|
|
89
85
|
# The empty turbo_stream response prevents Turbo from navigating (which causes scroll-to-top).
|
|
90
86
|
def respond_with_stream_or_redirect(message)
|
|
@@ -94,56 +90,14 @@ module CoPlan
|
|
|
94
90
|
end
|
|
95
91
|
end
|
|
96
92
|
|
|
97
|
-
# Replaces a thread in place (status changed
|
|
93
|
+
# Replaces a thread in place (status changed).
|
|
98
94
|
def broadcast_thread_replace(thread)
|
|
99
95
|
Broadcaster.replace_to(
|
|
100
96
|
@plan,
|
|
101
97
|
target: dom_id(thread),
|
|
102
|
-
partial: "coplan/comment_threads/
|
|
98
|
+
partial: "coplan/comment_threads/thread_popover",
|
|
103
99
|
locals: { thread: thread, plan: @plan }
|
|
104
100
|
)
|
|
105
|
-
broadcast_tab_counts
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Moves a thread between Open/Resolved lists and updates tab counts.
|
|
109
|
-
def broadcast_thread_move(thread, from:, to:)
|
|
110
|
-
Broadcaster.remove_to(@plan, target: dom_id(thread))
|
|
111
|
-
Broadcaster.append_to(
|
|
112
|
-
@plan,
|
|
113
|
-
target: to,
|
|
114
|
-
partial: "coplan/comment_threads/thread",
|
|
115
|
-
locals: { thread: thread, plan: @plan }
|
|
116
|
-
)
|
|
117
|
-
broadcast_tab_counts
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def broadcast_tab_counts
|
|
121
|
-
threads = @plan.comment_threads
|
|
122
|
-
open_count = threads.active.count
|
|
123
|
-
resolved_count = threads.archived.count
|
|
124
|
-
|
|
125
|
-
Broadcaster.update_to(
|
|
126
|
-
@plan,
|
|
127
|
-
target: "open-thread-count",
|
|
128
|
-
html: open_count > 0 ? open_count.to_s : ""
|
|
129
|
-
)
|
|
130
|
-
Broadcaster.update_to(
|
|
131
|
-
@plan,
|
|
132
|
-
target: "resolved-thread-count",
|
|
133
|
-
html: resolved_count > 0 ? resolved_count.to_s : ""
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
# Toggle empty-state placeholders
|
|
137
|
-
Broadcaster.replace_to(
|
|
138
|
-
@plan,
|
|
139
|
-
target: "open-threads-empty",
|
|
140
|
-
html: %(<p class="text-sm text-muted" id="open-threads-empty" #{'style="display: none;"' if open_count > 0}>No open comments.</p>)
|
|
141
|
-
)
|
|
142
|
-
Broadcaster.replace_to(
|
|
143
|
-
@plan,
|
|
144
|
-
target: "resolved-threads-empty",
|
|
145
|
-
html: %(<p class="text-sm text-muted" id="resolved-threads-empty" #{'style="display: none;"' if resolved_count > 0}>No resolved comments.</p>)
|
|
146
|
-
)
|
|
147
101
|
end
|
|
148
102
|
end
|
|
149
103
|
end
|
|
@@ -10,9 +10,7 @@ module CoPlan
|
|
|
10
10
|
|
|
11
11
|
def show
|
|
12
12
|
authorize!(@plan, :show?)
|
|
13
|
-
threads = @plan.comment_threads.includes(:comments, :created_by_user
|
|
14
|
-
@active_threads = threads.active
|
|
15
|
-
@archived_threads = threads.archived
|
|
13
|
+
@threads = @plan.comment_threads.includes(:comments, :created_by_user).order(:created_at)
|
|
16
14
|
end
|
|
17
15
|
|
|
18
16
|
def edit
|