@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1/**
2 * @requires javelin-install
3 * javelin-behavior-device
4 * javelin-dom
5 * javelin-external-editor-link-engine
6 * javelin-magical-init
7 * javelin-stratcom
8 * javelin-uri
9 * javelin-util
10 * javelin-vector
11 * javelin-workflow
12 * phuix-action-view
13 * phuix-action-list-view
14 * phuix-button-view
15 * phuix-dropdown-menu
16 * phuix-icon-view
17 * phabricator-diff-changeset
18 * phabricator-diff-tree-view
19 * phabricator-keyboard-shortcut
20 * phabricator-notification
21 * @provides phabricator-diff-changeset-list
22 *
23 * @javelin-installs JX.DiffChangesetList
24 *
25 * @javelin
26 */
27
28JX.install('DiffChangesetList', {
29
30 construct: function() {
31 this._changesets = [];
32
33 var onload = JX.bind(this, this._ifawake, this._onload);
34 JX.Stratcom.listen('click', 'differential-load', onload);
35
36 var onmore = JX.bind(this, this._ifawake, this._onmore);
37 JX.Stratcom.listen('click', 'show-more', onmore);
38
39 var onmenu = JX.bind(this, this._ifawake, this._onmenu);
40 JX.Stratcom.listen('click', 'differential-view-options', onmenu);
41
42 var onexpand = JX.bind(this, this._ifawake, this._oncollapse, false);
43 JX.Stratcom.listen('click', 'reveal-inline', onexpand);
44
45 var onresize = JX.bind(this, this._ifawake, this._onresize);
46 JX.Stratcom.listen('resize', null, onresize);
47
48 var onscroll = JX.bind(this, this._ifawake, this._onscroll);
49 JX.Stratcom.listen('scroll', null, onscroll);
50
51 JX.enableDispatch(window, 'selectstart');
52
53 var onselect = JX.bind(this, this._ifawake, this._onselect);
54 JX.Stratcom.listen(
55 ['mousedown', 'selectstart'],
56 ['differential-inline-comment', 'differential-inline-header'],
57 onselect);
58
59 var onhover = JX.bind(this, this._ifawake, this._onhover);
60 JX.Stratcom.listen(
61 ['mouseover', 'mouseout'],
62 'differential-inline-comment',
63 onhover);
64
65 var onrangedown = JX.bind(this, this._ifawake, this._onrangedown);
66 JX.Stratcom.listen(
67 'mousedown',
68 ['differential-changeset', 'tag:td'],
69 onrangedown);
70
71 var onrangemove = JX.bind(this, this._ifawake, this._onrangemove);
72 JX.Stratcom.listen(
73 ['mouseover', 'mouseout'],
74 ['differential-changeset', 'tag:td'],
75 onrangemove);
76
77 var onrangeup = JX.bind(this, this._ifawake, this._onrangeup);
78 JX.Stratcom.listen(
79 'mouseup',
80 null,
81 onrangeup);
82
83 var onrange = JX.bind(this, this._ifawake, this._onSelectRange);
84 JX.enableDispatch(window, 'selectionchange');
85 JX.Stratcom.listen('selectionchange', null, onrange);
86
87 this._setupInlineCommentListeners();
88 },
89
90 properties: {
91 translations: null,
92 inlineURI: null,
93 inlineListURI: null,
94 isStandalone: false,
95 formationView: null
96 },
97
98 members: {
99 _initialized: false,
100 _asleep: true,
101 _changesets: null,
102
103 _cursorItem: null,
104
105 _focusNode: null,
106 _focusStart: null,
107 _focusEnd: null,
108
109 _hoverInline: null,
110 _hoverOrigin: null,
111 _hoverTarget: null,
112
113 _rangeActive: false,
114 _rangeOrigin: null,
115 _rangeTarget: null,
116
117 _bannerNode: null,
118 _unsavedButton: null,
119 _unsubmittedButton: null,
120 _doneButton: null,
121 _doneMode: null,
122
123 _dropdownMenu: null,
124 _menuButton: null,
125 _menuItems: null,
126 _selectedChangeset: null,
127
128 sleep: function() {
129 this._asleep = true;
130
131 this._redrawFocus();
132 this._redrawSelection();
133 this.resetHover();
134
135 this._bannerChangeset = null;
136 this._redrawBanner();
137 },
138
139 wake: function() {
140 this._asleep = false;
141
142 this._redrawFocus();
143 this._redrawSelection();
144
145 this._bannerChangeset = null;
146 this._redrawBanner();
147
148 this._redrawFiletree();
149
150 if (this._initialized) {
151 return;
152 }
153
154 this._initialized = true;
155 var pht = this.getTranslations();
156
157 // We may be viewing the normal "/D123" view (with all the changesets)
158 // or the standalone view (with just one changeset). In the standalone
159 // view, some options (like jumping to next or previous file) do not
160 // make sense and do not function.
161 var standalone = this.getIsStandalone();
162
163 var label;
164
165 if (!standalone) {
166 label = pht('Jump to the table of contents.');
167 this._installKey('t', 'diff-nav', label, this._ontoc);
168
169 label = pht('Jump to the comment area.');
170 this._installKey('x', 'diff-nav', label, this._oncomments);
171 }
172
173 label = pht('Jump to next change.');
174 this._installJumpKey('j', label, 1);
175
176 label = pht('Jump to previous change.');
177 this._installJumpKey('k', label, -1);
178
179 if (!standalone) {
180 label = pht('Jump to next file.');
181 this._installJumpKey('J', label, 1, 'file');
182
183 label = pht('Jump to previous file.');
184 this._installJumpKey('K', label, -1, 'file');
185 }
186
187 label = pht('Jump to next inline comment.');
188 this._installJumpKey('n', label, 1, 'comment');
189
190 label = pht('Jump to previous inline comment.');
191 this._installJumpKey('p', label, -1, 'comment');
192
193 label = pht('Jump to next inline comment, including collapsed comments.');
194 this._installJumpKey('N', label, 1, 'comment', true);
195
196 label = pht(
197 'Jump to previous inline comment, including collapsed comments.');
198 this._installJumpKey('P', label, -1, 'comment', true);
199
200 var formation = this.getFormationView();
201 if (formation) {
202 var filetree = formation.getColumn(0);
203 var toggletree = JX.bind(filetree, filetree.toggleVisibility);
204 label = pht('Hide or show the paths panel.');
205 this._installKey('f', 'diff-vis', label, toggletree);
206 }
207
208 if (!standalone) {
209 label = pht('Hide or show the current changeset.');
210 this._installKey('h', 'diff-vis', label, this._onkeytogglefile);
211 }
212
213 label = pht('Reply to selected inline comment or change.');
214 this._installKey('r', 'inline', label,
215 JX.bind(this, this._onkeyreply, false));
216
217 label = pht('Reply and quote selected inline comment.');
218 this._installKey('R', 'inline', label,
219 JX.bind(this, this._onkeyreply, true));
220
221 label = pht('Add new inline comment on selected source text.');
222 this._installKey('c', 'inline', label,
223 JX.bind(this, this._onKeyCreate));
224
225 label = pht('Edit selected inline comment.');
226 this._installKey('e', 'inline', label, this._onkeyedit);
227
228 label = pht('Mark or unmark selected inline comment as done.');
229 this._installKey('w', 'inline', label, this._onkeydone);
230
231 label = pht('Collapse or expand inline comment.');
232 this._installKey('q', 'diff-vis', label, this._onkeycollapse);
233
234 label = pht('Hide or show all inline comments.');
235 this._installKey('A', 'diff-vis', label, this._onkeyhideall);
236
237 label = pht('Show path in repository.');
238 this._installKey('d', 'diff-nav', label, this._onkeyshowpath);
239
240 label = pht('Show directory in repository.');
241 this._installKey('D', 'diff-nav', label, this._onkeyshowdirectory);
242
243 label = pht('Open file in external editor.');
244 this._installKey('\\', 'diff-nav', label, this._onkeyopeneditor);
245 },
246
247 isAsleep: function() {
248 return this._asleep;
249 },
250
251 newChangesetForNode: function(node) {
252 var changeset = JX.DiffChangeset.getForNode(node);
253
254 this._changesets.push(changeset);
255 changeset.setChangesetList(this);
256
257 return changeset;
258 },
259
260 getChangesetForNode: function(node) {
261 return JX.DiffChangeset.getForNode(node);
262 },
263
264 getInlineByID: function(id) {
265 var inline = null;
266
267 for (var ii = 0; ii < this._changesets.length; ii++) {
268 inline = this._changesets[ii].getInlineByID(id);
269 if (inline) {
270 break;
271 }
272 }
273
274 return inline;
275 },
276
277 _ifawake: function(f) {
278 // This function takes another function and only calls it if the
279 // changeset list is awake, so we basically just ignore events when we
280 // are asleep. This may move up the stack at some point as we do more
281 // with Quicksand/Sheets.
282
283 if (this.isAsleep()) {
284 return;
285 }
286
287 return f.apply(this, [].slice.call(arguments, 1));
288 },
289
290 _onload: function(e) {
291 var data = e.getNodeData('differential-load');
292
293 // NOTE: We can trigger a load from either an explicit "Load" link on
294 // the changeset, or by clicking a link in the table of contents. If
295 // the event was a table of contents link, we let the anchor behavior
296 // run normally.
297 if (data.kill) {
298 e.kill();
299 }
300
301 var node = JX.$(data.id);
302 var changeset = this.getChangesetForNode(node);
303
304 changeset.load();
305
306 // TODO: Move this into Changeset.
307 var routable = changeset.getRoutable();
308 if (routable) {
309 routable.setPriority(2000);
310 }
311 },
312
313 _installKey: function(key, group, label, handler) {
314 handler = JX.bind(this, this._ifawake, handler);
315
316 return new JX.KeyboardShortcut(key, label)
317 .setHandler(handler)
318 .setGroup(group)
319 .register();
320 },
321
322 _installJumpKey: function(key, label, delta, filter, show_collapsed) {
323 filter = filter || null;
324
325 var options = {
326 filter: filter,
327 collapsed: show_collapsed
328 };
329
330 var handler = JX.bind(this, this._onjumpkey, delta, options);
331 return this._installKey(key, 'diff-nav', label, handler);
332 },
333
334 _ontoc: function(manager) {
335 var toc = JX.$('toc');
336 manager.scrollTo(toc);
337 },
338
339 _oncomments: function(manager) {
340 var reply = JX.$('reply');
341 manager.scrollTo(reply);
342 },
343
344 getSelectedInline: function() {
345 var cursor = this._cursorItem;
346
347 if (cursor) {
348 if (cursor.type == 'comment') {
349 return cursor.target;
350 }
351 }
352
353 return null;
354 },
355
356 _onkeyreply: function(is_quote) {
357 var cursor = this._cursorItem;
358
359 if (cursor) {
360 if (cursor.type == 'comment') {
361 var inline = cursor.target;
362 if (inline.canReply()) {
363 this.setFocus(null);
364 inline.reply(is_quote);
365 return;
366 }
367 }
368
369 // If the keyboard cursor is selecting a range of lines, we may have
370 // a mixture of old and new changes on the selected rows. It is not
371 // entirely unambiguous what the user means when they say they want
372 // to reply to this, but we use this logic: reply on the new file if
373 // there are any new lines. Otherwise (if there are only removed
374 // lines) reply on the old file.
375
376 if (cursor.type == 'change') {
377 var cells = this._getLineNumberCellsForChangeBlock(
378 cursor.nodes.begin,
379 cursor.nodes.end);
380
381 cursor.changeset.newInlineForRange(cells.src, cells.dst);
382
383 this.setFocus(null);
384 return;
385 }
386 }
387
388 var pht = this.getTranslations();
389 this._warnUser(pht('You must select a comment or change to reply to.'));
390 },
391
392 _getLineNumberCellsForChangeBlock: function(origin, target) {
393 // The "origin" and "target" are entire rows, but we need to find
394 // a range of cell nodes to actually create an inline, so go
395 // fishing.
396
397 var old_list = [];
398 var new_list = [];
399
400 var row = origin;
401 while (row) {
402 var header = row.firstChild;
403 while (header) {
404 if (this.getLineNumberFromHeader(header)) {
405 if (header.className.indexOf('old') !== -1) {
406 old_list.push(header);
407 } else if (header.className.indexOf('new') !== -1) {
408 new_list.push(header);
409 }
410 }
411 header = header.nextSibling;
412 }
413
414 if (row == target) {
415 break;
416 }
417
418 row = row.nextSibling;
419 }
420
421 var use_list;
422 if (new_list.length) {
423 use_list = new_list;
424 } else {
425 use_list = old_list;
426 }
427
428 var src = use_list[0];
429 var dst = use_list[use_list.length - 1];
430
431 return {
432 src: src,
433 dst: dst
434 };
435 },
436
437 _onkeyedit: function() {
438 var cursor = this._cursorItem;
439
440 if (cursor) {
441 if (cursor.type == 'comment') {
442 var inline = cursor.target;
443 if (inline.canEdit()) {
444 this.setFocus(null);
445
446 inline.edit();
447 return;
448 }
449 }
450 }
451
452 var pht = this.getTranslations();
453 this._warnUser(pht('You must select a comment to edit.'));
454 },
455
456 _onKeyCreate: function() {
457 var start = this._sourceSelectionStart;
458 var end = this._sourceSelectionEnd;
459
460 if (!this._sourceSelectionStart) {
461 var pht = this.getTranslations();
462 this._warnUser(
463 pht(
464 'You must select source text to create a new inline comment.'));
465 return;
466 }
467
468 this._setSourceSelection(null, null);
469
470 var changeset = start.changeset;
471
472 var config = {};
473 if (changeset.getResponseDocumentEngineKey() === null) {
474 // If the changeset is using a document renderer, we ignore the
475 // selection range and just treat this as a comment from the first
476 // block to the last block.
477
478 // If we don't discard the range, we later render a bogus highlight
479 // if the block content is complex (like a Jupyter notebook cell
480 // with images).
481
482 config.startOffset = start.offset;
483 config.endOffset = end.offset;
484 }
485
486 changeset.newInlineForRange(start.targetNode, end.targetNode, config);
487 },
488
489 _onkeydone: function() {
490 var cursor = this._cursorItem;
491
492 if (cursor) {
493 if (cursor.type == 'comment') {
494 var inline = cursor.target;
495 if (inline.canDone()) {
496 this.setFocus(null);
497
498 inline.toggleDone();
499 return;
500 }
501 }
502 }
503
504 var pht = this.getTranslations();
505 this._warnUser(pht('You must select a comment to mark done.'));
506 },
507
508 _onkeytogglefile: function() {
509 var pht = this.getTranslations();
510 var changeset = this._getChangesetForKeyCommand();
511
512 if (!changeset) {
513 this._warnUser(pht('You must select a file to hide or show.'));
514 return;
515 }
516
517 changeset.toggleVisibility();
518 },
519
520 _getChangesetForKeyCommand: function() {
521 var cursor = this._cursorItem;
522
523 var changeset;
524 if (cursor) {
525 changeset = cursor.changeset;
526 }
527
528 if (!changeset) {
529 changeset = this._getVisibleChangeset();
530 }
531
532 return changeset;
533 },
534
535 _onkeyopeneditor: function(e) {
536 var pht = this.getTranslations();
537 var changeset = this._getChangesetForKeyCommand();
538
539 if (!changeset) {
540 this._warnUser(pht('You must select a file to edit.'));
541 return;
542 }
543
544 this._openEditor(changeset);
545 },
546
547 _openEditor: function(changeset) {
548 var pht = this.getTranslations();
549
550 var editor_template = changeset.getEditorURITemplate();
551 if (editor_template === null) {
552 this._warnUser(pht('No external editor is configured.'));
553 return;
554 }
555
556 var line = null;
557
558 // See PHI1749. We aren't exactly sure what the user intends when they
559 // use the keyboard to select a change block and then activate the
560 // "Open in Editor" function: they might mean to open the old or new
561 // offset, and may have the old or new state (or some other state) in
562 // their working copy.
563
564 // For now, pick: the new state line number if one exists; or the old
565 // state line number if one does not. If nothing else, this behavior is
566 // simple.
567
568 // If there's a document engine, just open the file to the first line.
569 // We currently can not map display blocks to source lines.
570
571 // If there's an inline, open the file to that line.
572
573 if (changeset.getResponseDocumentEngineKey() === null) {
574 var cursor = this._cursorItem;
575 if (cursor && (cursor.changeset === changeset)) {
576 if (cursor.type == 'change') {
577 var cells = this._getLineNumberCellsForChangeBlock(
578 cursor.nodes.begin,
579 cursor.nodes.end);
580 line = this.getLineNumberFromHeader(cells.src);
581 }
582
583 if (cursor.type === 'comment') {
584 var inline = cursor.target;
585 line = inline.getLineNumber();
586 }
587 }
588 }
589
590 var variables = {
591 l: line || 1
592 };
593
594 var editor_uri = new JX.ExternalEditorLinkEngine()
595 .setTemplate(editor_template)
596 .setVariables(variables)
597 .newURI();
598
599 JX.$U(editor_uri).go();
600 },
601
602 _onkeyshowpath: function() {
603 this._onrepositorykey(false);
604 },
605
606 _onkeyshowdirectory: function() {
607 this._onrepositorykey(true);
608 },
609
610 _onrepositorykey: function(is_directory) {
611 var pht = this.getTranslations();
612 var changeset = this._getChangesetForKeyCommand();
613
614 if (!changeset) {
615 this._warnUser(pht('You must select a file to open.'));
616 return;
617 }
618
619 var show_uri;
620 if (is_directory) {
621 show_uri = changeset.getShowDirectoryURI();
622 } else {
623 show_uri = changeset.getShowPathURI();
624 }
625
626 if (show_uri === null) {
627 return;
628 }
629
630 window.open(show_uri);
631 },
632
633 _onkeycollapse: function() {
634 var cursor = this._cursorItem;
635
636 if (cursor) {
637 if (cursor.type == 'comment') {
638 var inline = cursor.target;
639 if (inline.canCollapse()) {
640 this.setFocus(null);
641
642 inline.setCollapsed(!inline.isCollapsed());
643 return;
644 }
645 }
646 }
647
648 var pht = this.getTranslations();
649 this._warnUser(pht('You must select a comment to hide.'));
650 },
651
652 _onkeyhideall: function() {
653 var inlines = this._getInlinesByType();
654 if (inlines.visible.length) {
655 this._toggleInlines('all');
656 } else {
657 this._toggleInlines('show');
658 }
659 },
660
661 _warnUser: function(message) {
662 new JX.Notification()
663 .setContent(message)
664 .alterClassName('jx-notification-alert', true)
665 .setDuration(3000)
666 .show();
667 },
668
669 _onjumpkey: function(delta, options) {
670 var state = this._getSelectionState();
671
672 var filter = options.filter || null;
673 var collapsed = options.collapsed || false;
674 var wrap = options.wrap || false;
675 var attribute = options.attribute || null;
676 var show = options.show || false;
677
678 var cursor = state.cursor;
679 var items = state.items;
680
681 // If there's currently no selection and the user tries to go back,
682 // don't do anything.
683 if ((cursor === null) && (delta < 0)) {
684 return;
685 }
686
687 var did_wrap = false;
688 while (true) {
689 if (cursor === null) {
690 cursor = 0;
691 } else {
692 cursor = cursor + delta;
693 }
694
695 // If we've gone backward past the first change, bail out.
696 if (cursor < 0) {
697 return;
698 }
699
700 // If we've gone forward off the end of the list, figure out where we
701 // should end up.
702 if (cursor >= items.length) {
703 if (!wrap) {
704 // If we aren't wrapping around, we're done.
705 return;
706 }
707
708 if (did_wrap) {
709 // If we're already wrapped around, we're done.
710 return;
711 }
712
713 // Otherwise, wrap the cursor back to the top.
714 cursor = 0;
715 did_wrap = true;
716 }
717
718 // If we're selecting things of a particular type (like only files)
719 // and the next item isn't of that type, move past it.
720 if (filter !== null) {
721 if (items[cursor].type !== filter) {
722 continue;
723 }
724 }
725
726 // If the item is collapsed, don't select it when iterating with jump
727 // keys. It can still potentially be selected in other ways.
728 if (!collapsed) {
729 if (items[cursor].collapsed) {
730 continue;
731 }
732 }
733
734 // If the item has been deleted, don't select it when iterating. The
735 // cursor may remain on it until it is removed.
736 if (items[cursor].deleted) {
737 continue;
738 }
739
740 // If we're selecting things with a particular attribute, like
741 // "unsaved", skip items without the attribute.
742 if (attribute !== null) {
743 if (!(items[cursor].attributes || {})[attribute]) {
744 continue;
745 }
746 }
747
748 // If this item is a hidden inline but we're clicking a button which
749 // selects inlines of a particular type, make it visible again.
750 if (items[cursor].hidden) {
751 if (!show) {
752 continue;
753 }
754 items[cursor].target.setHidden(false);
755 }
756
757 // Otherwise, we've found a valid item to select.
758 break;
759 }
760
761 this._setSelectionState(items[cursor], true);
762 },
763
764 _getSelectionState: function() {
765 var items = this._getSelectableItems();
766
767 var cursor = null;
768 if (this._cursorItem !== null) {
769 for (var ii = 0; ii < items.length; ii++) {
770 var item = items[ii];
771 if (this._cursorItem.target === item.target) {
772 cursor = ii;
773 break;
774 }
775 }
776 }
777
778 return {
779 cursor: cursor,
780 items: items
781 };
782 },
783
784 selectChangeset: function(changeset, scroll) {
785 var items = this._getSelectableItems();
786
787 var cursor = null;
788 for (var ii = 0; ii < items.length; ii++) {
789 var item = items[ii];
790 if (changeset === item.target) {
791 cursor = ii;
792 break;
793 }
794 }
795
796 if (cursor !== null) {
797 this._setSelectionState(items[cursor], scroll);
798 } else {
799 this._setSelectionState(null, false);
800 }
801
802 return this;
803 },
804
805 _setSelectionState: function(item, scroll) {
806 var old = this._cursorItem;
807
808 if (old) {
809 if (old.type === 'comment') {
810 old.target.setIsSelected(false);
811 }
812 }
813
814 this._cursorItem = item;
815
816 if (item) {
817 if (item.type === 'comment') {
818 item.target.setIsSelected(true);
819 }
820 }
821
822 this._redrawSelection(scroll);
823
824 return this;
825 },
826
827 _redrawSelection: function(scroll) {
828 var cursor = this._cursorItem;
829 if (!cursor) {
830 this.setFocus(null);
831 return;
832 }
833
834 // If this item has been removed from the document (for example: create
835 // a new empty comment, then use the "Unsaved" button to select it, then
836 // cancel it), we can still keep the cursor here but do not want to show
837 // a selection reticle over an invisible node.
838 if (cursor.deleted) {
839 this.setFocus(null);
840 return;
841 }
842
843 var changeset = cursor.changeset;
844
845 var tree = this._getTreeView();
846 if (changeset) {
847 tree.setSelectedPath(cursor.changeset.getPathView());
848 } else {
849 tree.setSelectedPath(null);
850 }
851
852 this._selectChangeset(changeset);
853
854 this.setFocus(cursor.nodes.begin, cursor.nodes.end);
855
856 if (scroll) {
857 var pos = JX.$V(cursor.nodes.begin);
858 JX.DOM.scrollToPosition(0, pos.y - 60);
859 }
860
861 return this;
862 },
863
864 redrawCursor: function() {
865 // NOTE: This is setting the cursor to the current cursor. Usually, this
866 // would have no effect.
867
868 // However, if the old cursor pointed at an inline and the inline has
869 // been edited so the rows have changed, this updates the cursor to point
870 // at the new inline with the proper rows for the current state, and
871 // redraws the reticle correctly.
872
873 var state = this._getSelectionState();
874 if (state.cursor !== null) {
875 this._setSelectionState(state.items[state.cursor], false);
876 }
877 },
878
879 _getSelectableItems: function() {
880 var result = [];
881
882 for (var ii = 0; ii < this._changesets.length; ii++) {
883 var items = this._changesets[ii].getSelectableItems();
884 for (var jj = 0; jj < items.length; jj++) {
885 result.push(items[jj]);
886 }
887 }
888
889 return result;
890 },
891
892 _onhover: function(e) {
893 if (e.getIsTouchEvent()) {
894 return;
895 }
896
897 var inline;
898 if (e.getType() == 'mouseout') {
899 inline = null;
900 } else {
901 inline = this._getInlineForEvent(e);
902 }
903
904 this._setHoverInline(inline);
905 },
906
907 _onmore: function(e) {
908 e.kill();
909
910 var node = e.getNode('differential-changeset');
911 var changeset = this.getChangesetForNode(node);
912
913 var data = e.getNodeData('show-more');
914 var target = e.getNode('context-target');
915
916 changeset.loadContext(data.range, target);
917 },
918
919 _onmenu: function(e) {
920 var button = e.getNode('differential-view-options');
921
922 var data = JX.Stratcom.getData(button);
923 if (data.menu) {
924 // We've already built this menu, so we can let the menu itself handle
925 // the event.
926 return;
927 }
928
929 e.prevent();
930
931 var pht = this.getTranslations();
932
933 var node = JX.DOM.findAbove(
934 button,
935 'div',
936 'differential-changeset');
937
938 var changeset_list = this;
939 var changeset = this.getChangesetForNode(node);
940
941 var menu = new JX.PHUIXDropdownMenu(button)
942 .setWidth(240);
943 var list = new JX.PHUIXActionListView();
944
945 var add_link = function(icon, name, href, local) {
946 var link = new JX.PHUIXActionView()
947 .setIcon(icon)
948 .setName(name)
949 .setHandler(function(e) {
950 if (local) {
951 window.location.assign(href);
952 } else {
953 window.open(href);
954 }
955 menu.close();
956 e.prevent();
957 });
958
959 if (href) {
960 link.setHref(href);
961 } else {
962 link
963 .setDisabled(true)
964 .setUnresponsive(true);
965 }
966
967 list.addItem(link);
968 return link;
969 };
970
971 var visible_item = new JX.PHUIXActionView()
972 .setKeyCommand('h')
973 .setHandler(function(e) {
974 e.prevent();
975 menu.close();
976
977 changeset.select(false);
978 changeset.toggleVisibility();
979 });
980 list.addItem(visible_item);
981
982 var reveal_item = new JX.PHUIXActionView()
983 .setIcon('fa-eye');
984 list.addItem(reveal_item);
985
986 list.addItem(
987 new JX.PHUIXActionView()
988 .setDivider(true));
989
990 var up_item = new JX.PHUIXActionView()
991 .setHandler(function(e) {
992 if (changeset.isLoaded()) {
993
994 // Don't let the user swap display modes if a comment is being
995 // edited, since they might lose their work. See PHI180.
996 var inlines = changeset.getInlines();
997 for (var ii = 0; ii < inlines.length; ii++) {
998 if (inlines[ii].isEditing()) {
999 changeset_list._warnUser(
1000 pht(
1001 'Finish editing inline comments before changing display ' +
1002 'modes.'));
1003 e.prevent();
1004 menu.close();
1005 return;
1006 }
1007 }
1008
1009 var renderer = changeset.getRendererKey();
1010 if (renderer == '1up') {
1011 renderer = '2up';
1012 } else {
1013 renderer = '1up';
1014 }
1015 changeset.reload({renderer: renderer});
1016 } else {
1017 changeset.reload();
1018 }
1019
1020 e.prevent();
1021 menu.close();
1022 });
1023 list.addItem(up_item);
1024
1025 var encoding_item = new JX.PHUIXActionView()
1026 .setIcon('fa-font')
1027 .setName(pht('Change Text Encoding...'))
1028 .setHandler(function(e) {
1029 var params = {
1030 encoding: changeset.getCharacterEncoding()
1031 };
1032
1033 new JX.Workflow('/services/encoding/', params)
1034 .setHandler(function(r) {
1035 changeset.reload({encoding: r.encoding});
1036 })
1037 .start();
1038
1039 e.prevent();
1040 menu.close();
1041 });
1042 list.addItem(encoding_item);
1043
1044 var highlight_item = new JX.PHUIXActionView()
1045 .setIcon('fa-sun-o')
1046 .setName(pht('Highlight As...'))
1047 .setHandler(function(e) {
1048 var params = {
1049 highlight: changeset.getHighlight()
1050 };
1051
1052 new JX.Workflow('/services/highlight/', params)
1053 .setHandler(function(r) {
1054 changeset.reload({highlight: r.highlight});
1055 })
1056 .start();
1057
1058 e.prevent();
1059 menu.close();
1060 });
1061 list.addItem(highlight_item);
1062
1063 var engine_item = new JX.PHUIXActionView()
1064 .setIcon('fa-file-image-o')
1065 .setName(pht('View As Document Type...'))
1066 .setHandler(function(e) {
1067 var options = changeset.getAvailableDocumentEngineKeys() || [];
1068 options = options.join(',');
1069
1070 var params = {
1071 engine: changeset.getResponseDocumentEngineKey(),
1072 options: options
1073 };
1074
1075 new JX.Workflow('/services/viewas/', params)
1076 .setHandler(function(r) {
1077 changeset.reload({engine: r.engine});
1078 })
1079 .start();
1080
1081 e.prevent();
1082 menu.close();
1083 });
1084 list.addItem(engine_item);
1085
1086 list.addItem(
1087 new JX.PHUIXActionView()
1088 .setDivider(true));
1089
1090 add_link('fa-external-link', pht('View Standalone'), data.standaloneURI);
1091
1092 add_link('fa-arrow-left', pht('Show Raw File (Left)'), data.leftURI);
1093 add_link('fa-arrow-right', pht('Show Raw File (Right)'), data.rightURI);
1094
1095 add_link(
1096 'fa-folder-open-o',
1097 pht('Show Directory in Repository'),
1098 changeset.getShowDirectoryURI())
1099 .setKeyCommand('D');
1100
1101 add_link(
1102 'fa-file-text-o',
1103 pht('Show Path in Repository'),
1104 changeset.getShowPathURI())
1105 .setKeyCommand('d');
1106
1107 var editor_template = changeset.getEditorURITemplate();
1108 if (editor_template !== null) {
1109 var editor_item = new JX.PHUIXActionView()
1110 .setIcon('fa-i-cursor')
1111 .setName(pht('Open in Editor'))
1112 .setKeyCommand('\\')
1113 .setHandler(function(e) {
1114
1115 changeset_list._openEditor(changeset);
1116
1117 e.prevent();
1118 menu.close();
1119 });
1120
1121 list.addItem(editor_item);
1122 } else {
1123 var configure_uri = changeset.getEditorConfigureURI();
1124 if (configure_uri !== null) {
1125 add_link('fa-wrench', pht('Configure Editor'), configure_uri);
1126 }
1127 }
1128
1129 menu.setContent(list.getNode());
1130
1131 menu.listen('open', function() {
1132 // When the user opens the menu, check if there are any "Show More"
1133 // links in the changeset body. If there aren't, disable the "Show
1134 // Entire File" menu item since it won't change anything.
1135
1136 var nodes = JX.DOM.scry(JX.$(data.containerID), 'a', 'show-more');
1137 if (nodes.length) {
1138 reveal_item
1139 .setDisabled(false)
1140 .setName(pht('Show All Context'))
1141 .setIcon('fa-arrows-v')
1142 .setHandler(function(e) {
1143 changeset.loadAllContext();
1144 e.prevent();
1145 menu.close();
1146 });
1147 } else {
1148 reveal_item
1149 .setDisabled(true)
1150 .setUnresponsive(true)
1151 .setIcon('fa-file')
1152 .setName(pht('All Context Shown'))
1153 .setHref(null);
1154 }
1155
1156 encoding_item.setDisabled(!changeset.isLoaded());
1157 highlight_item.setDisabled(!changeset.isLoaded());
1158 engine_item.setDisabled(!changeset.isLoaded());
1159
1160 if (changeset.isLoaded()) {
1161 if (changeset.getRendererKey() == '2up') {
1162 up_item
1163 .setIcon('fa-list-alt')
1164 .setName(pht('View Unified Diff'));
1165 } else {
1166 up_item
1167 .setIcon('fa-columns')
1168 .setName(pht('View Side-by-Side Diff'));
1169 }
1170 } else {
1171 up_item
1172 .setIcon('fa-refresh')
1173 .setName(pht('Load Changes'));
1174 }
1175
1176 visible_item
1177 .setDisabled(true)
1178 .setIcon('fa-eye-slash')
1179 .setName(pht('Hide Changeset'));
1180
1181 var diffs = JX.DOM.scry(
1182 JX.$(data.containerID),
1183 'table',
1184 'differential-diff');
1185
1186 if (diffs.length > 1) {
1187 JX.$E(
1188 'More than one node with sigil "differential-diff" was found in "'+
1189 data.containerID+'."');
1190 } else if (diffs.length == 1) {
1191 visible_item.setDisabled(false);
1192 } else {
1193 // Do nothing when there is no diff shown in the table. For example,
1194 // the file is binary.
1195 }
1196
1197 });
1198
1199 data.menu = menu;
1200 changeset.setViewMenu(menu);
1201 menu.open();
1202 },
1203
1204 _oncollapse: function(is_collapse, e) {
1205 e.kill();
1206
1207 var inline = this._getInlineForEvent(e);
1208
1209 inline.setCollapsed(is_collapse);
1210 },
1211
1212 _onresize: function() {
1213 this._redrawFocus();
1214 this._redrawSelection();
1215
1216 // Force a banner redraw after a resize event. Particularly, this makes
1217 // sure the inline state updates immediately after an inline edit
1218 // operation, even if the changeset itself has not changed.
1219 this._bannerChangeset = null;
1220
1221 this._redrawBanner();
1222
1223 var changesets = this._changesets;
1224 for (var ii = 0; ii < changesets.length; ii++) {
1225 changesets[ii].redrawFileTree();
1226 }
1227 },
1228
1229 _onscroll: function() {
1230 this._redrawBanner();
1231 },
1232
1233 _onselect: function(e) {
1234 // If the user clicked some element inside the header, like an action
1235 // icon, ignore the event. They have to click the header element itself.
1236 if (e.getTarget() !== e.getNode('differential-inline-header')) {
1237 return;
1238 }
1239
1240 // If the user has double-clicked or triple-clicked a header, we want to
1241 // toggle the inline selection mode, not select text. Kill select events
1242 // originating with this element as the target.
1243 if (e.getType() === 'selectstart') {
1244 e.kill();
1245 return;
1246 }
1247
1248 var inline = this._getInlineForEvent(e);
1249 if (!inline) {
1250 return;
1251 }
1252
1253 // NOTE: Don't kill or prevent the event. In particular, we want this
1254 // click to clear any text selection as it normally would.
1255
1256 this.selectInline(inline);
1257 },
1258
1259 selectInline: function(inline, force, scroll) {
1260 var selection = this._getSelectionState();
1261 var item;
1262
1263 if (!force) {
1264 // If the comment the user clicked is currently selected, deselect it.
1265 // This makes it easy to undo things if you clicked by mistake.
1266 if (selection.cursor !== null) {
1267 item = selection.items[selection.cursor];
1268 if (item.target === inline) {
1269 this._setSelectionState(null, false);
1270 return;
1271 }
1272 }
1273 }
1274
1275 // Otherwise, select the item that the user clicked. This makes it
1276 // easier to resume keyboard operations after using the mouse to do
1277 // something else.
1278 var items = selection.items;
1279 for (var ii = 0; ii < items.length; ii++) {
1280 item = items[ii];
1281 if (item.target === inline) {
1282 this._setSelectionState(item, scroll);
1283 }
1284 }
1285
1286 },
1287
1288 redrawPreview: function() {
1289 // TODO: This isn't the cleanest way to find the preview form, but
1290 // rendering no longer has direct access to it.
1291 var forms = JX.DOM.scry(document.body, 'form', 'transaction-append');
1292 if (forms.length) {
1293 JX.DOM.invoke(forms[0], 'shouldRefresh');
1294 }
1295
1296 // Clear the mouse hover reticle after a substantive edit: we don't get
1297 // a "mouseout" event if the row vanished because of row being removed
1298 // after an edit.
1299 this.resetHover();
1300 },
1301
1302 setFocus: function(node, extended_node) {
1303 if (!node) {
1304 var tree = this._getTreeView();
1305 tree.setSelectedPath(null);
1306 this._selectChangeset(null);
1307 }
1308
1309 this._focusStart = node;
1310 this._focusEnd = extended_node;
1311 this._redrawFocus();
1312 },
1313
1314 _selectChangeset: function(changeset) {
1315 if (this._selectedChangeset === changeset) {
1316 return;
1317 }
1318
1319 if (this._selectedChangeset !== null) {
1320 this._selectedChangeset.setIsSelected(false);
1321 this._selectedChangeset = null;
1322 }
1323
1324 this._selectedChangeset = changeset;
1325 if (this._selectedChangeset !== null) {
1326 this._selectedChangeset.setIsSelected(true);
1327 }
1328 },
1329
1330 _redrawFocus: function() {
1331 var node = this._focusStart;
1332 var extended_node = this._focusEnd || node;
1333
1334 var reticle = this._getFocusNode();
1335 if (!node || this.isAsleep()) {
1336 JX.DOM.remove(reticle);
1337 return;
1338 }
1339
1340 // Outset the reticle some pixels away from the element, so there's some
1341 // space between the focused element and the outline.
1342 var p = JX.Vector.getPos(node);
1343 var s = JX.Vector.getAggregateScrollForNode(node);
1344 var d = JX.Vector.getDim(node);
1345
1346 p.add(s).add(d.x + 1, 4).setPos(reticle);
1347 // Compute the size we need to extend to the full extent of the focused
1348 // nodes.
1349 JX.Vector.getPos(extended_node)
1350 .add(-p.x, -p.y)
1351 .add(0, JX.Vector.getDim(extended_node).y)
1352 .add(10, -4)
1353 .setDim(reticle);
1354
1355 JX.DOM.getContentFrame().appendChild(reticle);
1356 },
1357
1358 _getFocusNode: function() {
1359 if (!this._focusNode) {
1360 var node = JX.$N('div', {className : 'keyboard-focus-focus-reticle'});
1361 this._focusNode = node;
1362 }
1363 return this._focusNode;
1364 },
1365
1366 _setHoverInline: function(inline) {
1367 var origin = null;
1368 var target = null;
1369
1370 if (inline) {
1371 var changeset = inline.getChangeset();
1372
1373 var changeset_id;
1374 var side = inline.getDisplaySide();
1375 if (side == 'right') {
1376 changeset_id = changeset.getRightChangesetID();
1377 } else {
1378 changeset_id = changeset.getLeftChangesetID();
1379 }
1380
1381 var new_part;
1382 if (inline.isNewFile()) {
1383 new_part = 'N';
1384 } else {
1385 new_part = 'O';
1386 }
1387
1388 var prefix = 'C' + changeset_id + new_part + 'L';
1389
1390 var number = inline.getLineNumber();
1391 var length = inline.getLineLength();
1392
1393 try {
1394 origin = JX.$(prefix + number);
1395 target = JX.$(prefix + (number + length));
1396 } catch (error) {
1397 // There may not be any nodes present in the document. A case where
1398 // this occurs is when you reply to a ghost inline which was made
1399 // on lines near the bottom of "long.txt" in an earlier diff, and
1400 // the file was later shortened so those lines no longer exist. For
1401 // more details, see T11662.
1402
1403 origin = null;
1404 target = null;
1405 }
1406 }
1407
1408 this._setHoverRange(origin, target, inline);
1409 },
1410
1411 _setHoverRange: function(origin, target, inline) {
1412 inline = inline || null;
1413
1414 var origin_dirty = (origin !== this._hoverOrigin);
1415 var target_dirty = (target !== this._hoverTarget);
1416 var inline_dirty = (inline !== this._hoverInline);
1417
1418 var any_dirty = (origin_dirty || target_dirty || inline_dirty);
1419 if (any_dirty) {
1420 this._hoverOrigin = origin;
1421 this._hoverTarget = target;
1422 this._hoverInline = inline;
1423 this._redrawHover();
1424 }
1425 },
1426
1427 resetHover: function() {
1428 this._setHoverRange(null, null, null);
1429 },
1430
1431 _redrawHover: function() {
1432 var map = this._hoverMap;
1433 if (map) {
1434 this._hoverMap = null;
1435 this._applyHoverHighlight(map, false);
1436 }
1437
1438 var rows = this._hoverRows;
1439 if (rows) {
1440 this._hoverRows = null;
1441 this._applyHoverHighlight(rows, false);
1442 }
1443
1444 if (!this._hoverOrigin || this.isAsleep()) {
1445 return;
1446 }
1447
1448 var top = this._hoverOrigin;
1449 var bot = this._hoverTarget;
1450 if (JX.$V(top).y > JX.$V(bot).y) {
1451 var tmp = top;
1452 top = bot;
1453 bot = tmp;
1454 }
1455
1456 // Find the leftmost cell that we're going to highlight. This is the
1457 // next sibling with a "data-copy-mode" attribute, which is a marker
1458 // for the cell with actual content in it.
1459 var content_cell = top;
1460 while (content_cell && !this._isContentCell(content_cell)) {
1461 content_cell = content_cell.nextSibling;
1462 }
1463
1464 // If we didn't find a cell to highlight, don't highlight anything.
1465 if (!content_cell) {
1466 return;
1467 }
1468
1469 rows = this._findContentCells(top, bot, content_cell);
1470
1471 var inline = this._hoverInline;
1472 if (!inline) {
1473 this._hoverRows = rows;
1474 this._applyHoverHighlight(this._hoverRows, true);
1475 return;
1476 }
1477
1478 if (!inline.hoverMap) {
1479 inline.hoverMap = this._newHoverMap(rows, inline);
1480 }
1481
1482 this._hoverMap = inline.hoverMap;
1483 this._applyHoverHighlight(this._hoverMap, true);
1484 },
1485
1486 _applyHoverHighlight: function(items, on) {
1487 for (var ii = 0; ii < items.length; ii++) {
1488 var item = items[ii];
1489
1490 JX.DOM.alterClass(item.lineNode, 'inline-hover', on);
1491 JX.DOM.alterClass(item.cellNode, 'inline-hover', on);
1492
1493 if (item.bright) {
1494 JX.DOM.alterClass(item.cellNode, 'inline-hover-bright', on);
1495 }
1496
1497 if (item.hoverNode) {
1498 if (on) {
1499 item.cellNode.insertBefore(
1500 item.hoverNode,
1501 item.cellNode.firstChild);
1502 } else {
1503 JX.DOM.remove(item.hoverNode);
1504 }
1505 }
1506 }
1507 },
1508
1509 _findContentCells: function(top, bot, content_cell) {
1510 var head_row = JX.DOM.findAbove(top, 'tr');
1511 var last_row = JX.DOM.findAbove(bot, 'tr');
1512
1513 var cursor = head_row;
1514 var rows = [];
1515 var idx = null;
1516 var ii;
1517 var line_cell = null;
1518 do {
1519 line_cell = null;
1520 for (ii = 0; ii < cursor.childNodes.length; ii++) {
1521 var child = cursor.childNodes[ii];
1522 if (!JX.DOM.isType(child, 'td')) {
1523 continue;
1524 }
1525
1526 if (child.getAttribute('data-n')) {
1527 line_cell = child;
1528 }
1529
1530 if (child === content_cell) {
1531 idx = ii;
1532 }
1533
1534 if (ii !== idx) {
1535 continue;
1536 }
1537
1538 if (this._isContentCell(child)) {
1539 rows.push({
1540 lineNode: line_cell,
1541 cellNode: child
1542 });
1543 }
1544
1545 break;
1546 }
1547
1548 if (cursor === last_row) {
1549 break;
1550 }
1551
1552 cursor = cursor.nextSibling;
1553 } while (cursor);
1554
1555 return rows;
1556 },
1557
1558 _newHoverMap: function(rows, inline) {
1559 var start = inline.getStartOffset();
1560 var end = inline.getEndOffset();
1561
1562 var info;
1563 var content;
1564 for (ii = 0; ii < rows.length; ii++) {
1565 info = this._getSelectionOffset(rows[ii].cellNode, null);
1566
1567 content = info.content;
1568 content = content.replace(/\n+$/, '');
1569
1570 rows[ii].content = content;
1571 }
1572
1573 var attr_dull = {
1574 className: 'inline-hover-text'
1575 };
1576
1577 var attr_bright = {
1578 className: 'inline-hover-text inline-hover-text-bright'
1579 };
1580
1581 var attr_container = {
1582 className: 'inline-hover-container'
1583 };
1584
1585 var min = 0;
1586 var max = rows.length - 1;
1587 var offset_min;
1588 var offset_max;
1589 var len;
1590 var node;
1591 var text;
1592 var any_highlight = false;
1593 for (ii = 0; ii < rows.length; ii++) {
1594 content = rows[ii].content;
1595 len = content.length;
1596
1597 if (ii === min && (start !== null)) {
1598 offset_min = start;
1599 } else {
1600 offset_min = 0;
1601 }
1602
1603 if (ii === max && (end !== null)) {
1604 offset_max = Math.min(end, len);
1605 } else {
1606 offset_max = len;
1607 }
1608
1609 var has_min = (offset_min > 0);
1610 var has_max = (offset_max < len);
1611
1612 if (has_min || has_max) {
1613 any_highlight = true;
1614 }
1615
1616 rows[ii].min = offset_min;
1617 rows[ii].max = offset_max;
1618 rows[ii].hasMin = has_min;
1619 rows[ii].hasMax = has_max;
1620 }
1621
1622 for (ii = 0; ii < rows.length; ii++) {
1623 content = rows[ii].content;
1624 offset_min = rows[ii].min;
1625 offset_max = rows[ii].max;
1626
1627 var has_highlight = (rows[ii].hasMin || rows[ii].hasMax);
1628
1629 if (any_highlight) {
1630 var parts = [];
1631
1632 if (offset_min > 0) {
1633 text = content.substring(0, offset_min);
1634 node = JX.$N('span', attr_dull, text);
1635 parts.push(node);
1636 }
1637
1638 if (len) {
1639 text = content.substring(offset_min, offset_max);
1640 node = JX.$N('span', attr_bright, text);
1641 parts.push(node);
1642 }
1643
1644 if (offset_max < len) {
1645 text = content.substring(offset_max, len);
1646 node = JX.$N('span', attr_dull, text);
1647 parts.push(node);
1648 }
1649
1650 rows[ii].hoverNode = JX.$N('div', attr_container, parts);
1651 } else {
1652 rows[ii].hoverNode = null;
1653 }
1654
1655 rows[ii].bright = (any_highlight && !has_highlight);
1656 }
1657
1658 return rows;
1659 },
1660
1661 _deleteInlineByID: function(id) {
1662 var uri = this.getInlineURI();
1663 var data = {
1664 op: 'refdelete',
1665 id: id
1666 };
1667
1668 var handler = JX.bind(this, this.redrawPreview);
1669
1670 new JX.Workflow(uri, data)
1671 .setHandler(handler)
1672 .start();
1673 },
1674
1675 _getInlineForEvent: function(e) {
1676 var node = e.getNode('differential-changeset');
1677 if (!node) {
1678 return null;
1679 }
1680
1681 var changeset = this.getChangesetForNode(node);
1682
1683 var inline_row = e.getNode('inline-row');
1684 return changeset.getInlineForRow(inline_row);
1685 },
1686
1687 getLineNumberFromHeader: function(node) {
1688 var n = parseInt(node.getAttribute('data-n'));
1689
1690 if (!n) {
1691 return null;
1692 }
1693
1694 // If this is a line number that's part of a row showing more context,
1695 // we don't want to let users leave inlines here.
1696
1697 try {
1698 JX.DOM.findAbove(node, 'tr', 'context-target');
1699 return null;
1700 } catch (ex) {
1701 // Ignore.
1702 }
1703
1704 return n;
1705 },
1706
1707 getDisplaySideFromHeader: function(th) {
1708 return (th.parentNode.firstChild != th) ? 'right' : 'left';
1709 },
1710
1711 _onrangedown: function(e) {
1712 // NOTE: We're allowing "mousedown" from a touch event through so users
1713 // can leave inlines on a single line.
1714
1715 // See PHI985. We want to exclude both right-mouse and middle-mouse
1716 // clicks from continuing.
1717 if (!e.isLeftButton()) {
1718 return;
1719 }
1720
1721 if (this._rangeActive) {
1722 return;
1723 }
1724
1725 var target = e.getTarget();
1726 var number = this.getLineNumberFromHeader(target);
1727 if (!number) {
1728 return;
1729 }
1730
1731 e.kill();
1732 this._rangeActive = true;
1733
1734 this._rangeOrigin = target;
1735 this._rangeTarget = target;
1736
1737 this._setHoverRange(this._rangeOrigin, this._rangeTarget);
1738 },
1739
1740 _onrangemove: function(e) {
1741 if (e.getIsTouchEvent()) {
1742 return;
1743 }
1744
1745 var is_out = (e.getType() == 'mouseout');
1746 var target = e.getTarget();
1747
1748 this._updateRange(target, is_out);
1749 },
1750
1751 _updateRange: function(target, is_out) {
1752 // Don't update the range if this target doesn't correspond to a line
1753 // number. For instance, this may be a dead line number, like the empty
1754 // line numbers on the left hand side of a newly added file.
1755 var number = this.getLineNumberFromHeader(target);
1756 if (!number) {
1757 return;
1758 }
1759
1760 if (this._rangeActive) {
1761 var origin = this._hoverOrigin;
1762
1763 // Don't update the reticle if we're selecting a line range and the
1764 // "<th />" under the cursor is on the wrong side of the file. You can
1765 // only leave inline comments on the left or right side of a file, not
1766 // across lines on both sides.
1767 var origin_side = this.getDisplaySideFromHeader(origin);
1768 var target_side = this.getDisplaySideFromHeader(target);
1769 if (origin_side != target_side) {
1770 return;
1771 }
1772
1773 // Don't update the reticle if we're selecting a line range and the
1774 // "<th />" under the cursor corresponds to a different file. You can
1775 // only leave inline comments on lines in a single file, not across
1776 // multiple files.
1777 var origin_table = JX.DOM.findAbove(origin, 'table');
1778 var target_table = JX.DOM.findAbove(target, 'table');
1779 if (origin_table != target_table) {
1780 return;
1781 }
1782 }
1783
1784 if (is_out) {
1785 if (this._rangeActive) {
1786 // If we're dragging a range, just leave the state as it is. This
1787 // allows you to drag over something invalid while selecting a
1788 // range without the range flickering or getting lost.
1789 } else {
1790 // Otherwise, clear the current range.
1791 this.resetHover();
1792 }
1793 return;
1794 }
1795
1796 if (this._rangeActive) {
1797 this._rangeTarget = target;
1798 } else {
1799 this._rangeOrigin = target;
1800 this._rangeTarget = target;
1801 }
1802
1803 this._setHoverRange(this._rangeOrigin, this._rangeTarget);
1804 },
1805
1806 _onrangeup: function(e) {
1807 if (!this._rangeActive) {
1808 return;
1809 }
1810
1811 e.kill();
1812
1813 var origin = this._rangeOrigin;
1814 var target = this._rangeTarget;
1815
1816 // If the user dragged a range from the bottom to the top, swap the node
1817 // order around.
1818 if (JX.$V(origin).y > JX.$V(target).y) {
1819 var tmp = target;
1820 target = origin;
1821 origin = tmp;
1822 }
1823
1824 var node = JX.DOM.findAbove(origin, null, 'differential-changeset');
1825 var changeset = this.getChangesetForNode(node);
1826
1827 changeset.newInlineForRange(origin, target);
1828
1829 this._rangeActive = false;
1830 this._rangeOrigin = null;
1831 this._rangeTarget = null;
1832
1833 this.resetHover();
1834 },
1835
1836 _redrawBanner: function() {
1837 // If the inline comment menu is open and we've done a redraw, close it.
1838 // In particular, this makes it close when you scroll the document:
1839 // otherwise, it stays open but the banner moves underneath it.
1840 if (this._dropdownMenu) {
1841 this._dropdownMenu.close();
1842 }
1843
1844 var node = this._getBannerNode();
1845 var changeset = this._getVisibleChangeset();
1846 var tree = this._getTreeView();
1847 var formation = this.getFormationView();
1848
1849 if (!changeset) {
1850 this._bannerChangeset = null;
1851 JX.DOM.remove(node);
1852 tree.setFocusedPath(null);
1853
1854 if (formation) {
1855 formation.repaint();
1856 }
1857
1858 return;
1859 }
1860
1861 // Don't do anything if nothing has changed. This seems to avoid some
1862 // flickering issues in Safari, at least.
1863 if (this._bannerChangeset === changeset) {
1864 return;
1865 }
1866 this._bannerChangeset = changeset;
1867
1868 var paths = tree.getPaths();
1869 for (var ii = 0; ii < paths.length; ii++) {
1870 var path = paths[ii];
1871 if (path.getChangeset() === changeset) {
1872 tree.setFocusedPath(path);
1873 }
1874 }
1875
1876 var inlines = this._getInlinesByType();
1877
1878 var unsaved = inlines.unsaved;
1879 var unsubmitted = inlines.unsubmitted;
1880 var undone = inlines.undone;
1881 var done = inlines.done;
1882 var draft_done = inlines.draftDone;
1883
1884 JX.DOM.alterClass(
1885 node,
1886 'diff-banner-has-unsaved',
1887 !!unsaved.length);
1888
1889 JX.DOM.alterClass(
1890 node,
1891 'diff-banner-has-unsubmitted',
1892 !!unsubmitted.length);
1893
1894 JX.DOM.alterClass(
1895 node,
1896 'diff-banner-has-draft-done',
1897 !!draft_done.length);
1898
1899 var pht = this.getTranslations();
1900 var unsaved_button = this._getUnsavedButton();
1901 var unsubmitted_button = this._getUnsubmittedButton();
1902 var done_button = this._getDoneButton();
1903 var menu_button = this._getMenuButton();
1904
1905 if (unsaved.length) {
1906 unsaved_button.setText(unsaved.length + ' ' + pht('Unsaved'));
1907 JX.DOM.show(unsaved_button.getNode());
1908 } else {
1909 JX.DOM.hide(unsaved_button.getNode());
1910 }
1911
1912 if (unsubmitted.length || draft_done.length) {
1913 var any_draft_count = unsubmitted.length + draft_done.length;
1914
1915 unsubmitted_button.setText(any_draft_count + ' ' + pht('Unsubmitted'));
1916 JX.DOM.show(unsubmitted_button.getNode());
1917 } else {
1918 JX.DOM.hide(unsubmitted_button.getNode());
1919 }
1920
1921 if (done.length || undone.length) {
1922 // If you haven't marked any comments as "Done", we just show text
1923 // like "3 Comments". If you've marked at least one done, we show
1924 // "1 / 3 Comments".
1925
1926 var done_text;
1927 if (done.length) {
1928 done_text = [
1929 done.length,
1930 ' / ',
1931 (done.length + undone.length),
1932 ' ',
1933 pht('Comments')
1934 ];
1935 } else {
1936 done_text = [
1937 undone.length,
1938 ' ',
1939 pht('Comments')
1940 ];
1941 }
1942
1943 done_button.setText(done_text);
1944
1945 JX.DOM.show(done_button.getNode());
1946
1947 // If any comments are not marked "Done", this cycles through the
1948 // missing comments. Otherwise, it cycles through all the saved
1949 // comments.
1950 if (undone.length) {
1951 this._doneMode = 'undone';
1952 } else {
1953 this._doneMode = 'done';
1954 }
1955
1956 } else {
1957 JX.DOM.hide(done_button.getNode());
1958 }
1959
1960 var path_view = [icon, ' ', changeset.getDisplayPath()];
1961
1962 var buttons_attrs = {
1963 className: 'diff-banner-buttons'
1964 };
1965
1966 var buttons_list = [
1967 unsaved_button.getNode(),
1968 unsubmitted_button.getNode(),
1969 done_button.getNode(),
1970 menu_button.getNode()
1971 ];
1972
1973 var buttons_view = JX.$N('div', buttons_attrs, buttons_list);
1974
1975 var icon = new JX.PHUIXIconView()
1976 .setIcon(changeset.getIcon())
1977 .getNode();
1978 JX.DOM.setContent(node, [buttons_view, path_view]);
1979
1980 document.body.appendChild(node);
1981
1982 if (formation) {
1983 formation.repaint();
1984 }
1985 },
1986
1987 _getInlinesByType: function() {
1988 var changesets = this._changesets;
1989 var unsaved = [];
1990 var unsubmitted = [];
1991 var undone = [];
1992 var done = [];
1993 var draft_done = [];
1994
1995 var visible_done = [];
1996 var visible_collapsed = [];
1997 var visible_ghosts = [];
1998 var visible = [];
1999 var hidden = [];
2000
2001 for (var ii = 0; ii < changesets.length; ii++) {
2002 var inlines = changesets[ii].getInlines();
2003 var inline;
2004 var jj;
2005 for (jj = 0; jj < inlines.length; jj++) {
2006 inline = inlines[jj];
2007
2008 if (inline.isDeleted()) {
2009 continue;
2010 }
2011
2012 if (inline.isSynthetic()) {
2013 continue;
2014 }
2015
2016 if (inline.isEditing()) {
2017 unsaved.push(inline);
2018 } else if (!inline.getID()) {
2019 // These are new comments which have been cancelled, and do not
2020 // count as anything.
2021 continue;
2022 } else if (inline.isDraft()) {
2023 unsubmitted.push(inline);
2024 } else {
2025 // NOTE: Unlike other states, an inline may be marked with a
2026 // draft checkmark and still be a "done" or "undone" comment.
2027 if (inline.isDraftDone()) {
2028 draft_done.push(inline);
2029 }
2030
2031 if (!inline.isDone()) {
2032 undone.push(inline);
2033 } else {
2034 done.push(inline);
2035 }
2036 }
2037 }
2038
2039 for (jj = 0; jj < inlines.length; jj++) {
2040 inline = inlines[jj];
2041 if (inline.isDeleted()) {
2042 continue;
2043 }
2044
2045 if (inline.isEditing()) {
2046 continue;
2047 }
2048
2049 if (inline.isHidden()) {
2050 hidden.push(inline);
2051 continue;
2052 }
2053
2054 visible.push(inline);
2055
2056 if (inline.isDone()) {
2057 visible_done.push(inline);
2058 }
2059
2060 if (inline.isCollapsed()) {
2061 visible_collapsed.push(inline);
2062 }
2063
2064 if (inline.isGhost()) {
2065 visible_ghosts.push(inline);
2066 }
2067 }
2068 }
2069
2070 return {
2071 unsaved: unsaved,
2072 unsubmitted: unsubmitted,
2073 undone: undone,
2074 done: done,
2075 draftDone: draft_done,
2076 visibleDone: visible_done,
2077 visibleGhosts: visible_ghosts,
2078 visibleCollapsed: visible_collapsed,
2079 visible: visible,
2080 hidden: hidden
2081 };
2082
2083 },
2084
2085 _getUnsavedButton: function() {
2086 if (!this._unsavedButton) {
2087 var button = new JX.PHUIXButtonView()
2088 .setIcon('fa-commenting-o')
2089 .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE);
2090
2091 var node = button.getNode();
2092
2093 var onunsaved = JX.bind(this, this._onunsavedclick);
2094 JX.DOM.listen(node, 'click', null, onunsaved);
2095
2096 this._unsavedButton = button;
2097 }
2098
2099 return this._unsavedButton;
2100 },
2101
2102 _getUnsubmittedButton: function() {
2103 if (!this._unsubmittedButton) {
2104 var button = new JX.PHUIXButtonView()
2105 .setIcon('fa-comment-o')
2106 .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE);
2107
2108 var node = button.getNode();
2109
2110 var onunsubmitted = JX.bind(this, this._onunsubmittedclick);
2111 JX.DOM.listen(node, 'click', null, onunsubmitted);
2112
2113 this._unsubmittedButton = button;
2114 }
2115
2116 return this._unsubmittedButton;
2117 },
2118
2119 _getDoneButton: function() {
2120 if (!this._doneButton) {
2121 var button = new JX.PHUIXButtonView()
2122 .setIcon('fa-comment')
2123 .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE);
2124
2125 var node = button.getNode();
2126
2127 var ondone = JX.bind(this, this._ondoneclick);
2128 JX.DOM.listen(node, 'click', null, ondone);
2129
2130 this._doneButton = button;
2131 }
2132
2133 return this._doneButton;
2134 },
2135
2136 _getMenuButton: function() {
2137 if (!this._menuButton) {
2138 var pht = this.getTranslations();
2139
2140 var button = new JX.PHUIXButtonView()
2141 .setIcon('fa-bars')
2142 .setButtonType(JX.PHUIXButtonView.BUTTONTYPE_SIMPLE)
2143 .setAuralLabel(pht('Display Options'));
2144
2145 var dropdown = new JX.PHUIXDropdownMenu(button.getNode());
2146 this._menuItems = {};
2147
2148 var list = new JX.PHUIXActionListView();
2149 dropdown.setContent(list.getNode());
2150
2151 var map = {
2152 hideDone: {
2153 type: 'done'
2154 },
2155 hideCollapsed: {
2156 type: 'collapsed'
2157 },
2158 hideGhosts: {
2159 type: 'ghosts'
2160 },
2161 hideAll: {
2162 type: 'all'
2163 },
2164 showAll: {
2165 type: 'show'
2166 }
2167 };
2168
2169 for (var k in map) {
2170 var spec = map[k];
2171
2172 var handler = JX.bind(this, this._onhideinlines, spec.type);
2173 var item = new JX.PHUIXActionView()
2174 .setHandler(handler);
2175
2176 list.addItem(item);
2177 this._menuItems[k] = item;
2178 }
2179
2180 dropdown.listen('open', JX.bind(this, this._ondropdown));
2181
2182 if (this.getInlineListURI()) {
2183 list.addItem(
2184 new JX.PHUIXActionView()
2185 .setDivider(true));
2186
2187 list.addItem(
2188 new JX.PHUIXActionView()
2189 .setIcon('fa-external-link')
2190 .setName(pht('List Inline Comments'))
2191 .setHref(this.getInlineListURI()));
2192 }
2193
2194 this._menuButton = button;
2195 this._dropdownMenu = dropdown;
2196 }
2197
2198 return this._menuButton;
2199 },
2200
2201 _ondropdown: function() {
2202 var inlines = this._getInlinesByType();
2203 var items = this._menuItems;
2204 var pht = this.getTranslations();
2205
2206 items.hideDone
2207 .setName(pht('Hide "Done" Inlines'))
2208 .setDisabled(!inlines.visibleDone.length);
2209
2210 items.hideCollapsed
2211 .setName(pht('Hide Collapsed Inlines'))
2212 .setDisabled(!inlines.visibleCollapsed.length);
2213
2214 items.hideGhosts
2215 .setName(pht('Hide Older Inlines'))
2216 .setDisabled(!inlines.visibleGhosts.length);
2217
2218 items.hideAll
2219 .setName(pht('Hide All Inlines'))
2220 .setDisabled(!inlines.visible.length);
2221
2222 items.showAll
2223 .setName(pht('Show All Inlines'))
2224 .setDisabled(!inlines.hidden.length);
2225 },
2226
2227 _onhideinlines: function(type, e) {
2228 this._dropdownMenu.close();
2229 e.prevent();
2230
2231 this._toggleInlines(type);
2232 },
2233
2234 _toggleInlines: function(type) {
2235 var inlines = this._getInlinesByType();
2236
2237 // Clear the selection state since we end up in a weird place if the
2238 // user hides the selected inline.
2239 this._setSelectionState(null);
2240
2241 var targets;
2242 var mode = true;
2243 switch (type) {
2244 case 'done':
2245 targets = inlines.visibleDone;
2246 break;
2247 case 'collapsed':
2248 targets = inlines.visibleCollapsed;
2249 break;
2250 case 'ghosts':
2251 targets = inlines.visibleGhosts;
2252 break;
2253 case 'all':
2254 targets = inlines.visible;
2255 break;
2256 case 'show':
2257 targets = inlines.hidden;
2258 mode = false;
2259 break;
2260 }
2261
2262 for (var ii = 0; ii < targets.length; ii++) {
2263 targets[ii].setHidden(mode);
2264 }
2265 },
2266
2267 _onunsavedclick: function(e) {
2268 e.kill();
2269
2270 var options = {
2271 filter: 'comment',
2272 wrap: true,
2273 show: true,
2274 attribute: 'unsaved'
2275 };
2276
2277 this._onjumpkey(1, options);
2278 },
2279
2280 _onunsubmittedclick: function(e) {
2281 e.kill();
2282
2283 var options = {
2284 filter: 'comment',
2285 wrap: true,
2286 show: true,
2287 attribute: 'anyDraft'
2288 };
2289
2290 this._onjumpkey(1, options);
2291 },
2292
2293 _ondoneclick: function(e) {
2294 e.kill();
2295
2296 var options = {
2297 filter: 'comment',
2298 wrap: true,
2299 show: true,
2300 attribute: this._doneMode
2301 };
2302
2303 this._onjumpkey(1, options);
2304 },
2305
2306 _getBannerNode: function() {
2307 if (!this._bannerNode) {
2308 var attributes = {
2309 className: 'diff-banner',
2310 id: 'diff-banner'
2311 };
2312
2313 this._bannerNode = JX.$N('div', attributes);
2314 }
2315
2316 return this._bannerNode;
2317 },
2318
2319 _getVisibleChangeset: function() {
2320 if (this.isAsleep()) {
2321 return null;
2322 }
2323
2324 if (JX.Device.getDevice() != 'desktop') {
2325 return null;
2326 }
2327
2328 // Never show the banner if we're very near the top of the page.
2329 var margin = 480;
2330 var s = JX.Vector.getScroll();
2331 if (s.y < margin) {
2332 return null;
2333 }
2334
2335 // We're going to find the changeset which spans an invisible line a
2336 // little underneath the bottom of the banner. This makes the header
2337 // tick over from "A.txt" to "B.txt" just as "A.txt" scrolls completely
2338 // offscreen.
2339 var detect_height = 64;
2340
2341 for (var ii = 0; ii < this._changesets.length; ii++) {
2342 var changeset = this._changesets[ii];
2343 var c = changeset.getVectors();
2344
2345 // If the changeset starts above the line...
2346 if (c.pos.y <= (s.y + detect_height)) {
2347 // ...and ends below the line, this is the current visible changeset.
2348 if ((c.pos.y + c.dim.y) >= (s.y + detect_height)) {
2349 return changeset;
2350 }
2351 }
2352 }
2353
2354 return null;
2355 },
2356
2357 _getTreeView: function() {
2358 if (!this._treeView) {
2359 var tree = new JX.DiffTreeView();
2360
2361 for (var ii = 0; ii < this._changesets.length; ii++) {
2362 var changeset = this._changesets[ii];
2363 tree.addPath(changeset.getPathView());
2364 }
2365
2366 this._treeView = tree;
2367 }
2368 return this._treeView;
2369 },
2370
2371 _redrawFiletree : function() {
2372 var formation = this.getFormationView();
2373
2374 if (!formation) {
2375 return;
2376 }
2377
2378 var filetree = formation.getColumn(0);
2379 var flank = filetree.getFlank();
2380
2381 var flank_body = flank.getBodyNode();
2382
2383 var tree = this._getTreeView();
2384 JX.DOM.setContent(flank_body, tree.getNode());
2385 },
2386
2387 _setupInlineCommentListeners: function() {
2388 var onsave = JX.bind(this, this._onInlineEvent, 'save');
2389 JX.Stratcom.listen(
2390 ['submit', 'didSyntheticSubmit'],
2391 'inline-edit-form',
2392 onsave);
2393
2394 var oncancel = JX.bind(this, this._onInlineEvent, 'cancel');
2395 JX.Stratcom.listen(
2396 'click',
2397 'inline-edit-cancel',
2398 oncancel);
2399
2400 var onundo = JX.bind(this, this._onInlineEvent, 'undo');
2401 JX.Stratcom.listen(
2402 'click',
2403 'differential-inline-comment-undo',
2404 onundo);
2405
2406 var ondone = JX.bind(this, this._onInlineEvent, 'done');
2407 JX.Stratcom.listen(
2408 'click',
2409 ['differential-inline-comment', 'differential-inline-done'],
2410 ondone);
2411
2412 var ondelete = JX.bind(this, this._onInlineEvent, 'delete');
2413 JX.Stratcom.listen(
2414 'click',
2415 ['differential-inline-comment', 'differential-inline-delete'],
2416 ondelete);
2417
2418 var onmenu = JX.bind(this, this._onInlineEvent, 'menu');
2419 JX.Stratcom.listen(
2420 'click',
2421 ['differential-inline-comment', 'inline-action-dropdown'],
2422 onmenu);
2423
2424 var ondraft = JX.bind(this, this._onInlineEvent, 'draft');
2425 JX.Stratcom.listen(
2426 'keydown',
2427 ['differential-inline-comment', 'tag:textarea'],
2428 ondraft);
2429
2430 var on_preview_view = JX.bind(this, this._onPreviewEvent, 'view');
2431 JX.Stratcom.listen(
2432 'click',
2433 'differential-inline-preview-jump',
2434 on_preview_view);
2435 },
2436
2437 _onPreviewEvent: function(action, e) {
2438 if (this.isAsleep()) {
2439 return;
2440 }
2441
2442 var data = e.getNodeData('differential-inline-preview-jump');
2443 var inline = this.getInlineByID(data.inlineCommentID);
2444 if (!inline) {
2445 return;
2446 }
2447
2448 e.kill();
2449
2450 switch (action) {
2451 case 'view':
2452 this.selectInline(inline, true, true);
2453 break;
2454 }
2455 },
2456
2457 _onInlineEvent: function(action, e) {
2458 if (this.isAsleep()) {
2459 return;
2460 }
2461
2462 if (action !== 'draft' && action !== 'menu') {
2463 e.kill();
2464 }
2465
2466 var inline = this._getInlineForEvent(e);
2467 var is_ref = false;
2468
2469 // If we don't have a natural inline object, the user may have clicked
2470 // an action (like "Delete") inside a preview element at the bottom of
2471 // the page.
2472
2473 // If they did, try to find an associated normal inline to act on, and
2474 // pretend they clicked that instead. This makes the overall state of
2475 // the page more consistent.
2476
2477 // However, there may be no normal inline (for example, because it is
2478 // on a version of the diff which is not visible). In this case, we
2479 // act by reference.
2480
2481 if (inline === null) {
2482 var data = e.getNodeData('differential-inline-comment');
2483 inline = this.getInlineByID(data.id);
2484 if (inline) {
2485 is_ref = true;
2486 } else {
2487 switch (action) {
2488 case 'delete':
2489 this._deleteInlineByID(data.id);
2490 return;
2491 }
2492 }
2493 }
2494
2495 // TODO: For normal operations, highlight the inline range here.
2496
2497 switch (action) {
2498 case 'save':
2499 inline.save();
2500 break;
2501 case 'cancel':
2502 inline.cancel();
2503 break;
2504 case 'undo':
2505 inline.undo();
2506 break;
2507 case 'done':
2508 inline.toggleDone();
2509 break;
2510 case 'delete':
2511 inline.delete(is_ref);
2512 break;
2513 case 'draft':
2514 inline.triggerDraft();
2515 break;
2516 case 'menu':
2517 var node = e.getNode('inline-action-dropdown');
2518 inline.activateMenu(node, e);
2519 break;
2520 }
2521 },
2522
2523 _onSelectRange: function(e) {
2524 this._updateSourceSelection();
2525 },
2526
2527 _updateSourceSelection: function() {
2528 var ranges = this._getSelectedRanges();
2529
2530 // In Firefox, selecting multiple rows gives us multiple ranges. In
2531 // Safari and Chrome, we get a single range.
2532 if (!ranges.length) {
2533 this._setSourceSelection(null, null);
2534 return;
2535 }
2536
2537 var min = 0;
2538 var max = ranges.length - 1;
2539
2540 var head = ranges[min].startContainer;
2541 var last = ranges[max].endContainer;
2542
2543 var head_loc = this._getFragmentLocation(head);
2544 var last_loc = this._getFragmentLocation(last);
2545
2546 if (head_loc === null || last_loc === null) {
2547 this._setSourceSelection(null, null);
2548 return;
2549 }
2550
2551 if (head_loc.changesetID !== last_loc.changesetID) {
2552 this._setSourceSelection(null, null);
2553 return;
2554 }
2555
2556 head_loc.offset += ranges[min].startOffset;
2557 last_loc.offset += ranges[max].endOffset;
2558
2559 this._setSourceSelection(head_loc, last_loc);
2560 },
2561
2562 _setSourceSelection: function(start, end) {
2563 var start_updated =
2564 !this._isSameSourceSelection(this._sourceSelectionStart, start);
2565
2566 var end_updated =
2567 !this._isSameSourceSelection(this._sourceSelectionEnd, end);
2568
2569 if (!start_updated && !end_updated) {
2570 return;
2571 }
2572
2573 this._sourceSelectionStart = start;
2574 this._sourceSelectionEnd = end;
2575
2576 if (!start) {
2577 this._closeSourceSelectionMenu();
2578 return;
2579 }
2580
2581 var menu;
2582 if (this._sourceSelectionMenu) {
2583 menu = this._sourceSelectionMenu;
2584 } else {
2585 menu = this._newSourceSelectionMenu();
2586 this._sourceSelectionMenu = menu;
2587 }
2588
2589 var pos = JX.$V(start.node)
2590 .add(0, -menu.getMenuNodeDimensions().y)
2591 .add(0, -24);
2592
2593 menu.setPosition(pos);
2594 menu.open();
2595 },
2596
2597 _newSourceSelectionMenu: function() {
2598 var pht = this.getTranslations();
2599
2600 var menu = new JX.PHUIXDropdownMenu(null)
2601 .setWidth(240);
2602
2603 // We need to disable autofocus for this menu, since it operates on the
2604 // text selection in the document. If we leave this enabled, opening the
2605 // menu immediately discards the selection.
2606 menu.setDisableAutofocus(true);
2607
2608 var list = new JX.PHUIXActionListView();
2609 menu.setContent(list.getNode());
2610
2611 var oncreate = JX.bind(this, this._onSourceSelectionMenuAction, 'create');
2612
2613 var comment_item = new JX.PHUIXActionView()
2614 .setIcon('fa-comment-o')
2615 .setName(pht('New Inline Comment'))
2616 .setKeyCommand('c')
2617 .setHandler(oncreate);
2618
2619 list.addItem(comment_item);
2620
2621 return menu;
2622 },
2623
2624 _onSourceSelectionMenuAction: function(action, e) {
2625 e.kill();
2626 this._closeSourceSelectionMenu();
2627
2628 switch (action) {
2629 case 'create':
2630 this._onKeyCreate();
2631 break;
2632 }
2633 },
2634
2635 _closeSourceSelectionMenu: function() {
2636 if (this._sourceSelectionMenu) {
2637 this._sourceSelectionMenu.close();
2638 }
2639 },
2640
2641 _isSameSourceSelection: function(u, v) {
2642 if (u === null && v === null) {
2643 return true;
2644 }
2645
2646 if (u === null && v !== null) {
2647 return false;
2648 }
2649
2650 if (u !== null && v === null) {
2651 return false;
2652 }
2653
2654 return (
2655 (u.changesetID === v.changesetID) &&
2656 (u.line === v.line) &&
2657 (u.displayColumn === v.displayColumn) &&
2658 (u.offset === v.offset)
2659 );
2660 },
2661
2662 _getFragmentLocation: function(fragment) {
2663 // Find the changeset containing the fragment.
2664 var changeset = null;
2665 try {
2666 var node = JX.DOM.findAbove(
2667 fragment,
2668 'div',
2669 'differential-changeset');
2670
2671 changeset = this.getChangesetForNode(node);
2672 if (!changeset) {
2673 return null;
2674 }
2675 } catch (ex) {
2676 return null;
2677 }
2678
2679 // Find the line number and display column for the fragment.
2680 var line = null;
2681 var column_count = -1;
2682 var has_new = false;
2683 var has_old = false;
2684 var offset = null;
2685 var target_node = null;
2686 var td;
2687 try {
2688
2689 // NOTE: In Safari, you can carefully select an entire line and then
2690 // move your mouse down slightly, causing selection of an empty
2691 // document fragment which is an immediate child of the next "<tr />".
2692
2693 // If the fragment is a direct child of a "<tr />" parent, assume the
2694 // user has done this and select the last child of the previous row
2695 // instead. It's possible there are other ways to do this, so this may
2696 // not always be the right rule.
2697
2698 // Otherwise, select the containing "<td />".
2699
2700 var is_end;
2701 if (JX.DOM.isType(fragment.parentNode, 'tr')) {
2702 // Assume this is Safari, and that the user has carefully selected a
2703 // row and then moved their mouse down a few pixels to select the
2704 // invisible fragment at the beginning of the next row.
2705 var cells = fragment.parentNode.previousSibling.childNodes;
2706 td = cells[cells.length - 1];
2707 is_end = true;
2708 } else {
2709 td = this._findContentCell(fragment);
2710 is_end = false;
2711 }
2712
2713 var cursor = td;
2714 while (cursor) {
2715 if (cursor.getAttribute('data-copy-mode')) {
2716 column_count++;
2717 } else {
2718 // In unified mode, the content column isn't currently marked
2719 // with an attribute, and we can't count content columns anyway.
2720 // Keep track of whether or not we see a "NL" (New Line) column
2721 // and/or an "OL" (Old Line) column to try to puzzle out which
2722 // side of the display change we're on.
2723
2724 if (cursor.id.match(/NL/)) {
2725 has_new = true;
2726 } else if (cursor.id.match(/OL/)) {
2727 has_old = true;
2728 }
2729 }
2730
2731 var n = parseInt(cursor.getAttribute('data-n'));
2732
2733 if (n) {
2734 if (line === null) {
2735 target_node = cursor;
2736 line = n;
2737 }
2738 }
2739
2740 cursor = cursor.previousSibling;
2741 }
2742
2743 if (!line) {
2744 return null;
2745 }
2746
2747 if (column_count < 0) {
2748 if (has_new || has_old) {
2749 if (has_new) {
2750 column_count = 1;
2751 } else {
2752 column_count = 0;
2753 }
2754 } else {
2755 return null;
2756 }
2757 }
2758
2759 var info = this._getSelectionOffset(td, fragment);
2760
2761 if (info.found) {
2762 offset = info.offset;
2763 } else {
2764 if (is_end) {
2765 offset = info.offset;
2766 } else {
2767 offset = 0;
2768 }
2769 }
2770 } catch (ex) {
2771 return null;
2772 }
2773
2774 var changeset_id;
2775 if (column_count > 0) {
2776 changeset_id = changeset.getRightChangesetID();
2777 } else {
2778 changeset_id = changeset.getLeftChangesetID();
2779 }
2780
2781 return {
2782 node: td,
2783 changeset: changeset,
2784 changesetID: changeset_id,
2785 line: line,
2786 displayColumn: column_count,
2787 offset: offset,
2788 targetNode: target_node
2789 };
2790 },
2791
2792 _getSelectionOffset: function(node, target) {
2793 // If this is an aural hint node in a unified diff, ignore it when
2794 // calculating the selection offset.
2795 if (node.getAttribute && node.getAttribute('data-aural')) {
2796 return {
2797 offset: 0,
2798 content: '',
2799 found: false
2800 };
2801 }
2802
2803 if (!node.childNodes || !node.childNodes.length) {
2804 return {
2805 offset: node.textContent.length,
2806 content: node.textContent,
2807 found: false
2808 };
2809 }
2810
2811 var found = false;
2812 var offset = 0;
2813 var content = '';
2814 for (var ii = 0; ii < node.childNodes.length; ii++) {
2815 var child = node.childNodes[ii];
2816
2817 if (child === target) {
2818 found = true;
2819 }
2820
2821 var spec = this._getSelectionOffset(child, target);
2822
2823 content += spec.content;
2824 if (!found) {
2825 offset += spec.offset;
2826 }
2827
2828 found = found || spec.found;
2829 }
2830
2831 return {
2832 offset: offset,
2833 content: content,
2834 found: found
2835 };
2836 },
2837
2838 _getSelectedRanges: function() {
2839 var ranges = [];
2840
2841 if (!window.getSelection) {
2842 return ranges;
2843 }
2844
2845 var selection = window.getSelection();
2846 for (var ii = 0; ii < selection.rangeCount; ii++) {
2847 var range = selection.getRangeAt(ii);
2848 if (range.collapsed) {
2849 continue;
2850 }
2851
2852 ranges.push(range);
2853 }
2854
2855 return ranges;
2856 },
2857
2858 _isContentCell: function(node) {
2859 return !!node.getAttribute('data-copy-mode');
2860 },
2861
2862 _findContentCell: function(node) {
2863 var cursor = node;
2864 while (true) {
2865 cursor = JX.DOM.findAbove(cursor, 'td');
2866 if (this._isContentCell(cursor)) {
2867 return cursor;
2868 }
2869 }
2870 }
2871
2872 }
2873
2874});