Merge lp:~mwhudson/loggerhead/ajaxy-changelog into lp:loggerhead

Proposed by Michael Hudson-Doyle
Status: Merged
Approved by: Paul Hummer
Approved revision: 307
Merged at revision: not available
Proposed branch: lp:~mwhudson/loggerhead/ajaxy-changelog
Merge into: lp:loggerhead
Diff against target: None lines
To merge this branch: bzr merge lp:~mwhudson/loggerhead/ajaxy-changelog
Reviewer Review Type Date Requested Status
Paul Hummer (community) Approve
Review via email: mp+4515@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :

The main point of this branch is not including the lists of files changed in the changelog view and, if the user clicks the disclosure triangle, loading this bit via AJAX (or AHAH, really).

It also thoroughly rejigs the way the animation is done to animate marginBottom instead of height (after reading the source for Fx.Slide from mootools...). If you artificially slow the anims down in custom.js you can see the difference fairly clearly. This bit could/should have been done in a different branch, oh well, sorry about that :)

307. By Michael Hudson-Doyle

merge trunk

Revision history for this message
Paul Hummer (rockstar) wrote :

Hi Michael-

  These changes look good. The animations look better now. I still think we
should seriously consider a lazr-js dependency, as I think that it would be a
great help to both the loggerhead project and the lazr-js project.

 vote approve
 status approved

Cheers,
Paul

--
Paul Hummer
http://theironlion.net
1024/862FF08F C921 E962 58F8 5547 6723 0E8C 1C4D 8AC5 862F F08F

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'loggerhead/apps/branch.py'
--- loggerhead/apps/branch.py 2009-02-18 10:36:48 +0000
+++ loggerhead/apps/branch.py 2009-03-13 05:36:26 +0000
@@ -15,6 +15,7 @@
15from loggerhead.controllers.inventory_ui import InventoryUI15from loggerhead.controllers.inventory_ui import InventoryUI
16from loggerhead.controllers.annotate_ui import AnnotateUI16from loggerhead.controllers.annotate_ui import AnnotateUI
17from loggerhead.controllers.revision_ui import RevisionUI17from loggerhead.controllers.revision_ui import RevisionUI
18from loggerhead.controllers.revlog_ui import RevLogUI
18from loggerhead.controllers.atom_ui import AtomUI19from loggerhead.controllers.atom_ui import AtomUI
19from loggerhead.controllers.download_ui import DownloadUI20from loggerhead.controllers.download_ui import DownloadUI
20from loggerhead.controllers.search_ui import SearchUI21from loggerhead.controllers.search_ui import SearchUI
@@ -82,6 +83,7 @@
82 'changes': ChangeLogUI,83 'changes': ChangeLogUI,
83 'files': InventoryUI,84 'files': InventoryUI,
84 'revision': RevisionUI,85 'revision': RevisionUI,
86 '+revlog': RevLogUI,
85 'download': DownloadUI,87 'download': DownloadUI,
86 'atom': AtomUI,88 'atom': AtomUI,
87 'search': SearchUI,89 'search': SearchUI,
8890
=== modified file 'loggerhead/controllers/changelog_ui.py'
--- loggerhead/controllers/changelog_ui.py 2008-12-05 18:52:44 +0000
+++ loggerhead/controllers/changelog_ui.py 2009-03-13 05:36:26 +0000
@@ -17,6 +17,8 @@
17# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA17# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
18#18#
1919
20import simplejson
21
20from paste.httpexceptions import HTTPServerError22from paste.httpexceptions import HTTPServerError
2123
22from loggerhead import util24from loggerhead import util
@@ -58,7 +60,10 @@
58 scan_list = revid_list[i:]60 scan_list = revid_list[i:]
59 change_list = scan_list[:pagesize]61 change_list = scan_list[:pagesize]
60 changes = list(history.get_changes(change_list))62 changes = list(history.get_changes(change_list))
61 history.add_changes(changes)63 data = {}
64 for i, c in enumerate(changes):
65 c.index = i
66 data[str(i)] = c.revid
62 except:67 except:
63 self.log.exception('Exception fetching changes')68 self.log.exception('Exception fetching changes')
64 raise HTTPServerError('Could not fetch changes')69 raise HTTPServerError('Could not fetch changes')
@@ -71,9 +76,6 @@
71 navigation.query = query76 navigation.query = query
72 util.fill_in_navigation(navigation)77 util.fill_in_navigation(navigation)
7378
74 # add parent & merge-point branch-nick info, in case it's useful
75 history.get_branch_nicks(changes)
76
77 # Directory Breadcrumbs79 # Directory Breadcrumbs
78 directory_breadcrumbs = (80 directory_breadcrumbs = (
79 util.directory_breadcrumbs(81 util.directory_breadcrumbs(
@@ -84,6 +86,7 @@
84 return {86 return {
85 'branch': self._branch,87 'branch': self._branch,
86 'changes': changes,88 'changes': changes,
89 'data': simplejson.dumps(data),
87 'util': util,90 'util': util,
88 'history': history,91 'history': history,
89 'revid': revid,92 'revid': revid,
9093
=== added file 'loggerhead/controllers/revlog_ui.py'
--- loggerhead/controllers/revlog_ui.py 1970-01-01 00:00:00 +0000
+++ loggerhead/controllers/revlog_ui.py 2009-03-13 05:36:26 +0000
@@ -0,0 +1,23 @@
1from loggerhead import util
2from loggerhead.controllers import TemplatedBranchView
3
4
5class RevLogUI(TemplatedBranchView):
6
7 template_path = 'loggerhead.templates.revlog'
8
9 def get_values(self, path, kwargs, headers):
10 history = self._history
11 revid = self.get_revid()
12
13 changes = list(history.get_changes([revid]))
14 history.add_changes(changes)
15 history.get_branch_nicks(changes)
16
17 return {
18 'branch': self._branch,
19 'entry': changes[0],
20 'util': util,
21 'revid': revid,
22 'url': self._branch.context_url,
23 }
024
=== added file 'loggerhead/static/images/spinner.gif'
1Binary files loggerhead/static/images/spinner.gif 1970-01-01 00:00:00 +0000 and loggerhead/static/images/spinner.gif 2009-03-16 03:05:49 +0000 differ25Binary files loggerhead/static/images/spinner.gif 1970-01-01 00:00:00 +0000 and loggerhead/static/images/spinner.gif 2009-03-16 03:05:49 +0000 differ
=== modified file 'loggerhead/static/javascript/changelog.js'
--- loggerhead/static/javascript/changelog.js 2009-02-20 03:30:31 +0000
+++ loggerhead/static/javascript/changelog.js 2009-03-16 02:58:10 +0000
@@ -53,15 +53,24 @@
53 revlogs.each(53 revlogs.each(
54 function(item, i)54 function(item, i)
55 {55 {
56 var item_slide = item.query('.revisioninfo');56 var revid = revids[item.get('id').replace('log-', '')];
57 var open_content = new Array();57 var collapsable = new Collapsable(
58 var close_content = new Array();58 {
59 open_content.push(item.query('.long_description'));59 expand_icon: item.query('.expand_icon'),
60 close_content.push(item.query('.short_description'));60 open_node: item.query('.long_description'),
61 var expand_icon = item.query('.expand_icon');61 close_node: item.query('.short_description'),
62 var collapsable = new Collapsable(item_slide, expand_icon, open_content, close_content, false);62 source: global_path + '+revlog/' + revid,
63 source_target: item.query('.source_target'),
64 loading: item.query('.loading'),
65 is_open: false
66 });
6367
64 item.query('.expand_revisioninfo').on('click',function(){collapsable.toggle();});68 item.query('.expand_revisioninfo a').on(
69 'click',
70 function(e) {
71 e.preventDefault();
72 collapsable.toggle();
73 });
65 item.collapsable = collapsable;74 item.collapsable = collapsable;
66 });75 });
6776
6877
=== modified file 'loggerhead/static/javascript/custom.js'
--- loggerhead/static/javascript/custom.js 2009-02-26 01:04:18 +0000
+++ loggerhead/static/javascript/custom.js 2009-03-16 02:58:10 +0000
@@ -75,30 +75,15 @@
75 setTimeout("Y.get('#search_terms').setStyle('display','none')", 300);75 setTimeout("Y.get('#search_terms').setStyle('display','none')", 300);
76}76}
7777
78function Collapsable(item, expand_icon, open_content, close_content, is_open)78function Collapsable(config)
79{79{
80 this.is_open = is_open;80 this.is_open = config.is_open;
81 this.item = item;81 this.source_target = config.source_target;
82 this.open_content = open_content;82 this.open_node = config.open_node;
83 this.close_content = close_content;83 this.close_node = config.close_node;
84 this.expand_icon = expand_icon;84 this.expand_icon = config.expand_icon;
8585 this.source = config.source;
86 if (this.is_open) {86 this.loading = config.loading;
87 this.height = item.get('region').height;
88 }
89 else {
90 this.height = null;
91 }
92
93 //var expander = new Fx.Slide(this.item, { duration: 200 } );
94 if (!this.is_open)
95 {
96 this.expand_icon.set('src',this.expand_icon.get('title'));
97 }
98 else
99 {
100 this.expand_icon.set('src',this.expand_icon.get('alt'));
101 }
102}87}
10388
104function get_height(node) {89function get_height(node) {
@@ -112,56 +97,90 @@
112 return height;97 return height;
113}98}
11499
100Collapsable.prototype._load_finished = function(tid, res)
101{
102 var newNode = Y.Node.create(res.responseText.split('\n').splice(1).join(''));
103 this.source_target.ancestor().replaceChild(newNode, this.source_target);
104 this.source_target = null;
105 this.source = null;
106 this.loading.setStyle('display', 'none');
107 this.open();
108};
109
115Collapsable.prototype.open = function()110Collapsable.prototype.open = function()
116{111{
117 if (this.height == null) {112 if (this.source) {
118 this.height = get_height(this.item);113 this.loading.setStyle('display', 'block');
119 }114 Y.io(
115 this.source,
116 {
117 on: {complete: this._load_finished},
118 context: this
119 });
120 return;
121 }
122
123 var open_height = get_height(this.open_node);
124
125 var close_height;
126 if (this.close_node) {
127 close_height = this.close_node.get('region').height;
128 }
129 else {
130 close_height = 0;
131 }
132
133 var container = this.open_node.ancestor('.container');
120134
121 var anim = new Y.Anim(135 var anim = new Y.Anim(
122 {136 {
123 node: this.item,137 node: container,
124 from: {138 from: {
125 height: 0139 marginBottom: close_height - open_height
126 },140 },
127 to: {141 to: {
128 height: this.height142 marginBottom: 0
129 },143 },
130 duration: 0.2144 duration: 0.2
131 });145 });
146
132 anim.on('end', this.openComplete, this);147 anim.on('end', this.openComplete, this);
133 this.item.setStyle('height', 0);148 container.setStyle('marginBottom', close_height - open_height);
134 this.item.setStyle('display', 'block');149 if (this.close_node) {
150 this.close_node.setStyle('display', 'none');
151 }
152 this.open_node.setStyle('display', 'block');
153 this.expand_icon.set('src', this.expand_icon.get('alt'));
135 anim.run();154 anim.run();
136};155};
137156
138Collapsable.prototype.openComplete = function()157Collapsable.prototype.openComplete = function()
139{158{
140 for (var i=0;i<this.open_content.length;++i)
141 {
142 this.open_content[i].setStyle('display','block');
143 }
144
145 for (var i=0;i<this.close_content.length;++i)
146 {
147 this.close_content[i].setStyle('display','none');
148 }
149
150 this.expand_icon.set('src',this.expand_icon.get('alt'));
151 this.is_open = true;159 this.is_open = true;
152};160};
153161
154Collapsable.prototype.close = function()162Collapsable.prototype.close = function()
155{163{
156 var item = this.item;164 var container = this.open_node.ancestor('.container');
165
166 var open_height = this.open_node.get('region').height;
167
168 var close_height;
169 if (this.close_node) {
170 close_height = get_height(this.close_node);
171 }
172 else {
173 close_height = 0;
174 }
175
157 var anim = new Y.Anim(176 var anim = new Y.Anim(
158 {177 {
159 node: this.item,178 node: container,
160 from: {179 from: {
161 height: this.height180 marginBottom: 0
162 },181 },
163 to: {182 to: {
164 height: 0183 marginBottom: close_height - open_height
165 },184 },
166 duration: 0.2185 duration: 0.2
167 });186 });
@@ -170,29 +189,18 @@
170};189};
171190
172Collapsable.prototype.closeComplete = function () {191Collapsable.prototype.closeComplete = function () {
173 this.item.setStyle('display', 'none');192 this.open_node.setStyle('display', 'none');
174 var i;193 if (this.close_node) {
175 for (i=0;i<this.open_content.length;++i)194 this.close_node.setStyle('display', 'block');
176 {195 }
177 this.open_content[i].setStyle('display','none');196 this.open_node.ancestor('.container').setStyle('marginBottom', 0);
178 }197 this.expand_icon.set('src', this.expand_icon.get('title'));
179
180 for (i=0;i<this.close_content.length;++i)
181 {
182 this.close_content[i].setStyle('display','block');
183 }
184 this.expand_icon.set('src',this.expand_icon.get('title'));
185 this.is_open = false;198 this.is_open = false;
186};199};
187200
188Collapsable.prototype.isOpen = function()
189{
190 return this.is_open;
191};
192
193Collapsable.prototype.toggle = function()201Collapsable.prototype.toggle = function()
194{202{
195 if (this.isOpen())203 if (this.is_open)
196 {204 {
197 this.close();205 this.close();
198 }206 }
199207
=== modified file 'loggerhead/static/javascript/diff.js'
--- loggerhead/static/javascript/diff.js 2009-02-26 02:06:31 +0000
+++ loggerhead/static/javascript/diff.js 2009-03-16 02:15:47 +0000
@@ -151,10 +151,17 @@
151 diffs.each(151 diffs.each(
152 function(item, i)152 function(item, i)
153 {153 {
154 var item_slide = item.next('.diffinfo');154 item.query('.expand_diff').on('click', function() { collapsable.toggle(); });
155 var expand_icon = item.query( '.expand_diff' );155 Y.log(item.ancestor().query('.diffinfo'));
156 var collapsable = new Collapsable(item_slide, expand_icon, [], [], true);156 var collapsable = new Collapsable(
157 item.query('.expand_diff').on('click', function(){collapsable.toggle();});157 {
158 item.collapsable=collapsable;158 expand_icon: item.query('.expand_diff'),
159 });159 open_node: item.ancestor().query('.diffinfo'),
160 close_node: null,
161 source: null,
162 source_target: null,
163 is_open: true
164 });
165 item.collapsable=collapsable;
166 });
160 });167 });
161168
=== modified file 'loggerhead/templates/changelog.pt'
--- loggerhead/templates/changelog.pt 2009-03-04 22:30:32 +0000
+++ loggerhead/templates/changelog.pt 2009-03-16 03:05:49 +0000
@@ -7,6 +7,9 @@
7 <link rel="alternate" type="application/atom+xml"7 <link rel="alternate" type="application/atom+xml"
8 tal:attributes="href python:url(['/atom']);8 tal:attributes="href python:url(['/atom']);
9 title string:RSS feed for ${branch/friendly_name}" />9 title string:RSS feed for ${branch/friendly_name}" />
10 <script type="text/javascript">
11 var revids = <tal:block content="data" />;
12 </script>
10 <script type="text/javascript"13 <script type="text/javascript"
11 tal:attributes="src python:branch.static_url('/static/javascript/changelog.js')"></script>14 tal:attributes="src python:branch.static_url('/static/javascript/changelog.js')"></script>
12 </metal:block>15 </metal:block>
@@ -67,7 +70,7 @@
67 <table id="logentries">70 <table id="logentries">
68 <tr class="logheader">71 <tr class="logheader">
69 <td class="revisionnumber">Rev</td>72 <td class="revisionnumber">Rev</td>
70 <td class="expandcell">&nbsp;</td>73 <td class="expandcell show_if_js">&nbsp;</td>
71 <td class="summarycell">Summary</td>74 <td class="summarycell">Summary</td>
72 <td class="authorcell">Author</td>75 <td class="authorcell">Author</td>
73 <td class="datecell">Date</td>76 <td class="datecell">Date</td>
@@ -76,55 +79,45 @@
76 </tr>79 </tr>
77 <tal:block tal:repeat="entry changes">80 <tal:block tal:repeat="entry changes">
78 <a tal:attributes="name string:entry-${entry/revno}"/>81 <a tal:attributes="name string:entry-${entry/revno}"/>
79 <tr tal:attributes="class string:blueRow${entry/parity} revision_log">82 <tr tal:attributes="class string:blueRow${entry/parity} revision_log; id string:log-${entry/index}">
80 <td class="revnro revnolink"><a tal:attributes="title python:'Show revision '+entry.revno;83 <td class="revnro revnolink"><a tal:attributes="title python:'Show revision '+entry.revno;
81 href python:url(['/revision', entry.revno], clear=1)"84 href python:url(['/revision', entry.revno], clear=1)"
82 tal:content="python:util.trunc(entry.revno)"></a>85 tal:content="python:util.trunc(entry.revno)"></a>
83 </td>86 </td>
84 <td class="expcell">87 <td class="expcell show_if_js">
85 <div class="expand_revisioninfo">88 <div class="expand_revisioninfo">
86 <!-- So, this is interesting. I'm using "alt" to have the correct URL for the image of the expanded icon89 <!-- So, this is interesting. I'm using "alt" to have the correct URL for the image of the expanded icon
87 and "title" tag for the contracted URL of the image. This is a bit of a hack, but it's better than90 and "title" tag for the contracted URL of the image. This is a bit of a hack, but it's better than
88 other approaches I tried :) -->91 other approaches I tried :) -->
89 <img tal:attributes="src python:branch.static_url('/static/images/treeCollapsed.png');92 <a href="#">
90 alt python:branch.static_url('/static/images/treeExpanded.png');93 <img tal:attributes="src python:branch.static_url('/static/images/treeCollapsed.png');
91 title python:branch.static_url('/static/images/treeCollapsed.png')"94 alt python:branch.static_url('/static/images/treeExpanded.png');
92 class="expand_icon" />95 title python:branch.static_url('/static/images/treeCollapsed.png')"
96 class="expand_icon" />
97 </a>
93 </div>98 </div>
94 </td>99 </td>
95 <td class="summcell"><div tal:attributes="class string:short_description">100 <td class="summcell">
96 <a tal:attributes="title python:'Show revision '+entry.revno;101 <div style="overflow: hidden">
97 href python:url(['/revision', entry.revno], clear=1);102 <div class="container">
98 class string:link"103 <div class="short_description">
99 tal:content="entry/short_comment"></a>104 <a tal:attributes="title python:'Show revision '+entry.revno;
100 </div>105 href python:url(['/revision', entry.revno], clear=1);
101 <div tal:attributes="class string:long_description;106 class string:link"
102 style string:display:none">107 tal:content="entry/short_comment"></a>
103 <a tal:attributes="title python:'Show revision '+entry.revno;108 <div class="loading" style="display:none;">
104 href python:url(['/revision', entry.revno], clear=1);109 <img tal:attributes="src python:branch.static_url('/static/images/spinner.gif')" />
105 class string:link"110 </div>
106 tal:content="structure python:util.fixed_width(entry.comment)"></a>111 </div>
107 </div>112 <div class="long_description" style="display: none">
108 <div class="revisioninfo" style="display:none;">113 <a tal:attributes="title python:'Show revision '+entry.revno;
109 <ul class="expandrev">114 href python:url(['/revision', entry.revno], clear=1);
110 <li class="mfrom" tal:repeat="parent python:entry.parents[1:]">115 class string:link"
111 <span class="revnolink">116 tal:content="structure python:util.fixed_width(entry.comment)"></a>
112 <a tal:attributes="href python:url(['/changes', parent.revno])"117 <div class="source_target">
113 tal:content="parent/revno"></a>118 </div>
114 </span>119 </div>
115 <a tal:condition="parent.branch_nick"120 </div>
116 tal:attributes="href python:url(['/changes'], start_revid=parent.revno)"
117 tal:content="python:'(' + parent.branch_nick + ')'"
118 title="Show history" class="link"></a>
119 </li>
120 <li class="mto" tal:repeat="merge_point entry/merge_points">
121 <a tal:attributes="href python:url(['/changes'], start_revid=merge_point.revno)"
122 tal:content="python:revno_with_nick(merge_point)"
123 title="Show history" class="link"></a>
124 </li>
125 <li class="committerli" tal:content="python:util.hide_email(entry.author)"></li>
126 <tal:block content="structure python:file_change_summary(url, entry)" />
127 </ul>
128 </div>121 </div>
129 </td>122 </td>
130 <td tal:content="python:util.trunc(util.hide_email(entry.author), 20)"123 <td tal:content="python:util.trunc(util.hide_email(entry.author), 20)"
131124
=== modified file 'loggerhead/templates/revision.pt'
--- loggerhead/templates/revision.pt 2009-03-05 22:56:59 +0000
+++ loggerhead/templates/revision.pt 2009-03-16 02:15:47 +0000
@@ -123,28 +123,32 @@
123 tal:content="item/filename">123 tal:content="item/filename">
124 </a>124 </a>
125 </div>125 </div>
126 <div class="diffinfo">126 <div style="overflow: hidden">
127 <div class="pseudotable unified"127 <div class="container">
128 tal:repeat="chunk item/chunks">128 <div class="diffinfo">
129129 <div class="pseudotable unified"
130 <tal:block condition="not:repeat/chunk/start">130 tal:repeat="chunk item/chunks">
131 <div class="pseudorow context">131
132 <div class="lineNumber separate"></div>132 <tal:block condition="not:repeat/chunk/start">
133 <div class="lineNumber second separate"></div>133 <div class="pseudorow context">
134 <div class="code separate"></div>134 <div class="lineNumber separate"></div>
135 <div class="clear"><!-- --></div>135 <div class="lineNumber second separate"></div>
136 <div class="code separate"></div>
137 <div class="clear"><!-- --></div>
138 </div>
139 </tal:block>
140
141 <div tal:repeat="line chunk/diff"
142 tal:attributes="class string:pseudorow ${line/type}-row">
143 <div class="lineNumber first"
144 tal:content="structure python:util.fill_div(line.old_lineno)"></div>
145 <div class="lineNumber second"
146 tal:content="structure python:util.fill_div(line.new_lineno)"></div>
147 <div tal:attributes="class string:code ${line/type}"
148 tal:content="structure python:util.fill_div(util.html_clean(line.line))"></div>
149 <div class="clear"><!-- --></div>
150 </div>
136 </div>151 </div>
137 </tal:block>
138
139 <div tal:repeat="line chunk/diff"
140 tal:attributes="class string:pseudorow ${line/type}-row">
141 <div class="lineNumber first"
142 tal:content="structure python:util.fill_div(line.old_lineno)"></div>
143 <div class="lineNumber second"
144 tal:content="structure python:util.fill_div(line.new_lineno)"></div>
145 <div tal:attributes="class string:code ${line/type}"
146 tal:content="structure python:util.fill_div(util.html_clean(line.line))"></div>
147 <div class="clear"><!-- --></div>
148 </div>152 </div>
149 </div>153 </div>
150 </div>154 </div>
151155
=== added file 'loggerhead/templates/revlog.pt'
--- loggerhead/templates/revlog.pt 1970-01-01 00:00:00 +0000
+++ loggerhead/templates/revlog.pt 2009-03-13 05:36:26 +0000
@@ -0,0 +1,21 @@
1<div class="revisioninfo">
2 <ul class="expandrev">
3 <li class="mfrom" tal:repeat="parent python:entry.parents[1:]">
4 <span class="revnolink">
5 <a tal:attributes="href python:url(['/changes', parent.revno])"
6 tal:content="parent/revno"></a>
7 </span>
8 <a tal:condition="parent.branch_nick"
9 tal:attributes="href python:url(['/changes'], start_revid=parent.revno)"
10 tal:content="python:'(' + parent.branch_nick + ')'"
11 title="Show history" class="link"></a>
12 </li>
13 <li class="mto" tal:repeat="merge_point entry/merge_points">
14 <a tal:attributes="href python:url(['/changes'], start_revid=merge_point.revno)"
15 tal:content="python:revno_with_nick(merge_point)"
16 title="Show history" class="link"></a>
17 </li>
18 <li class="committerli" tal:content="python:util.hide_email(entry.author)"></li>
19 <tal:block content="structure python:file_change_summary(url, entry)" />
20 </ul>
21</div>

Subscribers

People subscribed via source and target branches