@chisquare-tech/origine-ngx-apps-code 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,11 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, Injectable, signal, computed, effect, untracked, Directive, ElementRef, input, output, HostListener, ChangeDetectionStrategy, Component } from '@angular/core';
2
+ import { inject, Injectable, signal, computed, effect, untracked, Directive, ElementRef, input, output, HostListener, ChangeDetectionStrategy, Component, DestroyRef } from '@angular/core';
3
3
  import { ApiService } from '@thescaffold/ngx-core';
4
4
  import { map, forkJoin, of } from 'rxjs';
5
5
  import * as i1 from '@angular/forms';
6
6
  import { FormsModule } from '@angular/forms';
7
7
  import { ActivatedRoute, RouterLink, Router } from '@angular/router';
8
+ import { NgTemplateOutlet } from '@angular/common';
8
9
  import { map as map$1, catchError } from 'rxjs/operators';
9
10
  import { marked } from 'marked';
10
11
 
@@ -95,10 +96,14 @@ class CodeApi {
95
96
  return this.api.get(`${this.repoPath(project, name)}/commit/${enc(sha)}`).pipe(map((r) => data(r)));
96
97
  }
97
98
  /** One file's hunks from base to head ("" base: the head's first parent). */
98
- diff(project, name, head, path, base = '') {
99
+ diff(project, name, head, path, base = '', opts = {}) {
99
100
  const p = { head, path };
100
101
  if (base)
101
102
  p['base'] = base;
103
+ if (opts.context)
104
+ p['context'] = String(opts.context);
105
+ if (opts.ignoreSpace)
106
+ p['w'] = 'ignore';
102
107
  return this.api.get(`${this.repoPath(project, name)}/diff`, p).pipe(map((r) => data(r)));
103
108
  }
104
109
  compare(project, name, base, head) {
@@ -153,6 +158,10 @@ class CodeApi {
153
158
  comment(project, name, n, c) {
154
159
  return this.api.post(`${this.repoPath(project, name)}/pulls/${n}/comments`, c).pipe(map((r) => data(r)));
155
160
  }
161
+ /** Discards one of the caller's pending review comments. */
162
+ discardComment(project, name, n, id) {
163
+ return this.api.delete(`${this.repoPath(project, name)}/pulls/${n}/comments/${enc(id)}`);
164
+ }
156
165
  review(project, name, n, state, body = '') {
157
166
  return this.api.post(`${this.repoPath(project, name)}/pulls/${n}/reviews`, { state, body }).pipe(map((r) => data(r)));
158
167
  }
@@ -414,22 +423,41 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
414
423
  args: ['document:click', ['$event']]
415
424
  }] } });
416
425
 
417
- // One file of a diff (prototype prDiffs): its header with +/−, the hunks highlighted, and, on a pull
418
- // request, the review threads on its lines and a comment form on any line number.
426
+ /** Past this many changed lines a file waits for "Load diff" (DEV-19 large diffs). */
427
+ const LARGE = 600;
428
+ /** Context steps for "Show more context". */
429
+ const CONTEXT = [3, 25, 100];
430
+ // One file of a diff (prototype prDiffs): its header with +/− and Viewed, the hunks highlighted,
431
+ // unified or split, and, on a pull request, the review threads on its lines and a comment form on
432
+ // any line number ("Add single comment" or part of a review). The diff loads when the file scrolls
433
+ // near the screen; a very large one waits to be asked for.
419
434
  class DiffView {
420
435
  constructor() {
421
436
  this.api = inject(CodeApi);
422
437
  this.host = inject(CodeHost);
438
+ this.el = inject((ElementRef));
439
+ this.destroyRef = inject(DestroyRef);
423
440
  this.repo = input.required(...(ngDevMode ? [{ debugName: "repo" }] : /* istanbul ignore next */ []));
424
441
  this.file = input.required(...(ngDevMode ? [{ debugName: "file" }] : /* istanbul ignore next */ []));
425
442
  this.head = input.required(...(ngDevMode ? [{ debugName: "head" }] : /* istanbul ignore next */ []));
426
443
  this.base = input('', ...(ngDevMode ? [{ debugName: "base" }] : /* istanbul ignore next */ []));
427
444
  this.threads = input([], ...(ngDevMode ? [{ debugName: "threads" }] : /* istanbul ignore next */ []));
428
445
  this.canComment = input(false, ...(ngDevMode ? [{ debugName: "canComment" }] : /* istanbul ignore next */ []));
446
+ this.mode = input('unified', ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
447
+ this.ignoreSpace = input(false, ...(ngDevMode ? [{ debugName: "ignoreSpace" }] : /* istanbul ignore next */ []));
448
+ /** The file is marked viewed (pull requests); null hides the Viewed button. */
449
+ this.viewed = input(null, ...(ngDevMode ? [{ debugName: "viewed" }] : /* istanbul ignore next */ []));
450
+ /** The caller has a pending review on this pull request. */
451
+ this.reviewing = input(false, ...(ngDevMode ? [{ debugName: "reviewing" }] : /* istanbul ignore next */ []));
429
452
  this.comment = output();
430
453
  this.reply = output();
431
454
  this.resolve = output();
455
+ this.discard = output();
456
+ this.viewedChange = output();
432
457
  this.open = signal(true, ...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
458
+ this.visible = signal(false, ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
459
+ this.waiting = signal(false, ...(ngDevMode ? [{ debugName: "waiting" }] : /* istanbul ignore next */ []));
460
+ this.context = signal(CONTEXT[0], ...(ngDevMode ? [{ debugName: "context" }] : /* istanbul ignore next */ []));
433
461
  this.diff = signal(null, ...(ngDevMode ? [{ debugName: "diff" }] : /* istanbul ignore next */ []));
434
462
  this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
435
463
  this.drafting = signal('', ...(ngDevMode ? [{ debugName: "drafting" }] : /* istanbul ignore next */ []));
@@ -437,25 +465,82 @@ class DiffView {
437
465
  this.replies = signal({}, ...(ngDevMode ? [{ debugName: "replies" }] : /* istanbul ignore next */ []));
438
466
  this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
439
467
  this.threadsHere = computed(() => this.threads().filter((t) => t.path === this.file().path), ...(ngDevMode ? [{ debugName: "threadsHere" }] : /* istanbul ignore next */ []));
468
+ this.viewable = computed(() => this.viewed() !== null, ...(ngDevMode ? [{ debugName: "viewable" }] : /* istanbul ignore next */ []));
469
+ this.me = computed(() => this.host.meId(), ...(ngDevMode ? [{ debugName: "me" }] : /* istanbul ignore next */ []));
470
+ this.splitRows = computed(() => (this.diff()?.hunks ?? []).map((h) => pair(h.lines)), ...(ngDevMode ? [{ debugName: "splitRows" }] : /* istanbul ignore next */ []));
440
471
  this.ago = (s) => ago(s);
472
+ this.asked = false;
473
+ effect(() => {
474
+ // viewed files fold away (and unfold when unmarked)
475
+ const v = this.viewed();
476
+ if (v !== null)
477
+ untracked(() => this.open.set(!v));
478
+ });
441
479
  effect(() => {
442
- const head = this.head();
443
- const base = this.base();
480
+ // what the diff depends on; it loads once the file is near the screen and open
481
+ this.head();
482
+ this.base();
483
+ this.ignoreSpace();
484
+ this.context();
444
485
  const f = this.file();
445
- const p = this.host.project();
446
- if (p && head && !f.binary)
447
- untracked(() => this.api.diff(p.id, this.repo(), head, f.path, base).subscribe({
448
- next: (d) => this.diff.set(d),
449
- error: (e) => this.error.set(apiMessage(e, 'Could not load this diff.')),
450
- }));
486
+ if (!this.visible() || !this.open() || f.binary)
487
+ return;
488
+ untracked(() => {
489
+ if (!this.asked && f.additions + f.deletions > LARGE) {
490
+ this.waiting.set(true);
491
+ return;
492
+ }
493
+ this.load();
494
+ });
451
495
  });
452
496
  }
497
+ ngAfterViewInit() {
498
+ if (typeof IntersectionObserver === 'undefined') {
499
+ this.visible.set(true);
500
+ return;
501
+ }
502
+ const io = new IntersectionObserver((entries) => {
503
+ if (entries.some((e) => e.isIntersecting)) {
504
+ this.visible.set(true);
505
+ io.disconnect();
506
+ }
507
+ }, { rootMargin: '600px 0px' });
508
+ io.observe(this.el.nativeElement);
509
+ this.destroyRef.onDestroy(() => io.disconnect());
510
+ }
511
+ load() {
512
+ const p = this.host.project();
513
+ const f = this.file();
514
+ if (!p || !this.head() || f.binary)
515
+ return;
516
+ this.asked = true;
517
+ this.waiting.set(false);
518
+ this.error.set('');
519
+ this.api.diff(p.id, this.repo(), this.head(), f.path, this.base(), { context: this.context(), ignoreSpace: this.ignoreSpace() }).subscribe({
520
+ next: (d) => this.diff.set(d),
521
+ error: (e) => this.error.set(apiMessage(e, 'Could not load this diff.')),
522
+ });
523
+ }
524
+ toggleOpen() {
525
+ this.open.set(!this.open());
526
+ }
527
+ moreContext() {
528
+ const next = CONTEXT.find((c) => c > this.context());
529
+ if (next)
530
+ this.context.set(next);
531
+ }
453
532
  key(l) {
454
533
  return `${l.kind === 'delete' ? 'old' : 'new'}:${this.lineOf(l)}`;
455
534
  }
456
535
  lineOf(l) {
457
536
  return (l.kind === 'delete' ? l.old : l.new) ?? 0;
458
537
  }
538
+ /** The lines of a split row that threads and drafts hang from (a context line once). */
539
+ rowLines(r) {
540
+ if (r.left && r.right && r.left === r.right)
541
+ return [r.left];
542
+ return [r.left, r.right].filter((l) => !!l);
543
+ }
459
544
  threadsAt(l) {
460
545
  const side = l.kind === 'delete' ? 'old' : 'new';
461
546
  const n = this.lineOf(l);
@@ -467,11 +552,11 @@ class DiffView {
467
552
  this.drafting.set(this.key(l));
468
553
  this.draftBody.set('');
469
554
  }
470
- send(l) {
555
+ send(l, pending) {
471
556
  const body = this.draftBody().trim();
472
557
  if (!body)
473
558
  return;
474
- this.comment.emit({ path: this.file().path, side: l.kind === 'delete' ? 'old' : 'new', line: this.lineOf(l), body });
559
+ this.comment.emit({ path: this.file().path, side: l.kind === 'delete' ? 'old' : 'new', line: this.lineOf(l), body, pending });
475
560
  this.drafting.set('');
476
561
  this.draftBody.set('');
477
562
  }
@@ -486,130 +571,254 @@ class DiffView {
486
571
  this.setReply(id, '');
487
572
  }
488
573
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: DiffView, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
489
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: DiffView, isStandalone: true, selector: "o-diff", inputs: { repo: { classPropertyName: "repo", publicName: "repo", isSignal: true, isRequired: true, transformFunction: null }, file: { classPropertyName: "file", publicName: "file", isSignal: true, isRequired: true, transformFunction: null }, head: { classPropertyName: "head", publicName: "head", isSignal: true, isRequired: true, transformFunction: null }, base: { classPropertyName: "base", publicName: "base", isSignal: true, isRequired: false, transformFunction: null }, threads: { classPropertyName: "threads", publicName: "threads", isSignal: true, isRequired: false, transformFunction: null }, canComment: { classPropertyName: "canComment", publicName: "canComment", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { comment: "comment", reply: "reply", resolve: "resolve" }, ngImport: i0, template: `
490
- <div class="o-card" [attr.data-testid]="'diff-' + file().path">
574
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: DiffView, isStandalone: true, selector: "o-diff", inputs: { repo: { classPropertyName: "repo", publicName: "repo", isSignal: true, isRequired: true, transformFunction: null }, file: { classPropertyName: "file", publicName: "file", isSignal: true, isRequired: true, transformFunction: null }, head: { classPropertyName: "head", publicName: "head", isSignal: true, isRequired: true, transformFunction: null }, base: { classPropertyName: "base", publicName: "base", isSignal: true, isRequired: false, transformFunction: null }, threads: { classPropertyName: "threads", publicName: "threads", isSignal: true, isRequired: false, transformFunction: null }, canComment: { classPropertyName: "canComment", publicName: "canComment", isSignal: true, isRequired: false, transformFunction: null }, mode: { classPropertyName: "mode", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, ignoreSpace: { classPropertyName: "ignoreSpace", publicName: "ignoreSpace", isSignal: true, isRequired: false, transformFunction: null }, viewed: { classPropertyName: "viewed", publicName: "viewed", isSignal: true, isRequired: false, transformFunction: null }, reviewing: { classPropertyName: "reviewing", publicName: "reviewing", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { comment: "comment", reply: "reply", resolve: "resolve", discard: "discard", viewedChange: "viewedChange" }, ngImport: i0, template: `
575
+ <div class="o-card" [attr.id]="'file-' + file().path" [attr.data-testid]="'diff-' + file().path">
491
576
  <div class="o-ch" style="background:var(--o-well)">
492
- <button type="button" class="o-btn o-ghost o-xs" (click)="open.set(!open())" [attr.aria-expanded]="open()" [attr.aria-label]="(open() ? 'Collapse ' : 'Expand ') + file().path">{{ open() ? '▾' : '▸' }}</button>
577
+ <button type="button" class="o-btn o-ghost o-xs" (click)="toggleOpen()" [attr.aria-expanded]="open()" [attr.aria-label]="(open() ? 'Collapse ' : 'Expand ') + file().path">{{ open() ? '▾' : '▸' }}</button>
493
578
  <span class="o-mono o-sm o-ell">{{ file().oldPath && file().oldPath !== file().path ? file().oldPath + ' → ' : '' }}{{ file().path }}</span>
494
579
  <span class="o-grn-t o-sm">+{{ file().additions }}</span><span class="o-red-t o-sm">−{{ file().deletions }}</span>
495
580
  @if (threadsHere().length) {<span class="o-faint o-sm">💬 {{ threadsHere().length }}</span>}
496
- <span class="o-r"><ng-content /></span>
581
+ <span class="o-r o-flex" style="gap:6px">
582
+ @if (open() && diff() && context() < 100 && !file().binary) {<button type="button" class="o-btn o-xs" (click)="moreContext()" [attr.aria-label]="'Show more context in ' + file().path">More context</button>}
583
+ <ng-content />
584
+ @if (viewable()) {
585
+ <button type="button" class="o-btn o-xs viewed" role="checkbox" [attr.aria-checked]="viewed()" [attr.aria-label]="'Viewed ' + file().path" (click)="viewedChange.emit(!viewed())"><span class="o-cbx" [class.o-on]="viewed()" style="width:12px;height:12px"></span>Viewed</button>
586
+ }
587
+ </span>
497
588
  </div>
498
589
  @if (open()) {
499
590
  @if (file().binary) {
500
591
  <div class="o-empty o-sm">A binary file.</div>
592
+ } @else if (waiting()) {
593
+ <div class="o-empty o-sm o-col" style="gap:8px;align-items:center">
594
+ <span>{{ file().additions + file().deletions }} changed lines.</span>
595
+ <button type="button" class="o-btn o-sm" (click)="waiting.set(false); load()">Load diff</button>
596
+ </div>
501
597
  } @else if (error()) {
502
598
  <div class="o-note o-red" role="alert">{{ error() }}</div>
503
599
  } @else if (diff(); as d) {
504
600
  @if (d.truncated) {<div class="o-note">This diff is too large to show in full.</div>}
505
- <div class="o-code" style="overflow-x:auto" role="table" [attr.aria-label]="'Changes to ' + file().path">
506
- @for (h of d.hunks; track $index) {
507
- <div class="hunk o-mono" role="row">{{ h.header }}</div>
508
- @for (l of h.lines; track $index) {
509
- <div class="o-ln" role="row" [class.o-add]="l.kind === 'add'" [class.o-del]="l.kind === 'delete'">
510
- <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.kind === 'delete' ? l.old : l.new }}</span>
511
- <span class="o-s" aria-hidden="true">{{ l.kind === 'add' ? '+' : l.kind === 'delete' ? '−' : ' ' }}</span>
512
- <span class="o-sr-only">{{ l.kind === 'add' ? 'added' : l.kind === 'delete' ? 'removed' : '' }}</span>
513
- <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
514
- </div>
515
- @for (t of threadsAt(l); track t.id) {
516
- <div class="o-thread" [attr.data-testid]="'thread-' + t.id">
517
- <div class="o-cb o-col" style="gap:6px;padding:10px 12px">
518
- @for (c of t.comments; track c.id) {
519
- <span class="o-sm"><b>{{ people().name(c.authorId) }}</b><span class="o-faint"> · {{ ago(c.createdAt) }}{{ c.pending ? ' · pending' : '' }}</span></span>
520
- <span style="white-space:pre-wrap">{{ c.body }}</span>
521
- }
601
+ @if (mode() === 'split') {
602
+ <div class="o-code split" style="overflow-x:auto" role="table" [attr.aria-label]="'Changes to ' + file().path + ', side by side'">
603
+ @for (h of d.hunks; track $index; let hi = $index) {
604
+ <div class="hunk o-mono full" role="row">{{ h.header }}</div>
605
+ @for (r of splitRows()[hi]; track $index) {
606
+ @if (r.left; as l) {
607
+ <div class="o-ln side-l" role="row" [class.o-del]="l.kind === 'delete'" [attr.data-testid]="'split-old-' + l.old">
608
+ <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.old }}</span>
609
+ <span class="o-sr-only">{{ l.kind === 'delete' ? 'removed' : '' }}</span>
610
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
522
611
  </div>
523
- @if (canComment()) {
524
- <div class="o-cf">
525
- <input class="o-in o-sm" placeholder="Reply…" aria-label="Reply" [ngModel]="replies()[t.id] ?? ''" (ngModelChange)="setReply(t.id, $event)" (keydown.enter)="sendReply(t.id)" />
526
- <button type="button" class="o-btn o-sm" (click)="sendReply(t.id)">Reply</button>
527
- <button type="button" class="o-btn o-sm" (click)="resolve.emit({ threadId: t.id, resolved: !t.resolved })">{{ t.resolved ? 'Unresolve' : 'Resolve' }}</button>
528
- </div>
529
- }
530
- </div>
612
+ } @else {<div class="o-ln side-l empty-side" aria-hidden="true"><span class="o-n"></span><span class="o-t"></span></div>}
613
+ @if (r.right; as l) {
614
+ <div class="o-ln" role="row" [class.o-add]="l.kind === 'add'" [attr.data-testid]="'split-new-' + l.new">
615
+ <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.new }}</span>
616
+ <span class="o-sr-only">{{ l.kind === 'add' ? 'added' : '' }}</span>
617
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
618
+ </div>
619
+ } @else {<div class="o-ln empty-side" aria-hidden="true"><span class="o-n"></span><span class="o-t"></span></div>}
620
+ @for (l of rowLines(r); track $index) {
621
+ @for (t of threadsAt(l); track t.id) {<div class="full"><ng-container *ngTemplateOutlet="threadTpl; context: { $implicit: t }" /></div>}
622
+ @if (drafting() === key(l)) {<div class="full"><ng-container *ngTemplateOutlet="draftTpl; context: { $implicit: l }" /></div>}
623
+ }
531
624
  }
532
- @if (drafting() === key(l)) {
533
- <div class="o-thread"><div class="o-cb o-col" style="gap:8px;padding:10px 12px">
534
- <textarea class="o-in" rows="2" [placeholder]="'Leave a comment on line ' + lineOf(l)" [attr.aria-label]="'Comment on line ' + lineOf(l)" [ngModel]="draftBody()" (ngModelChange)="draftBody.set($event)" data-testid="line-comment"></textarea>
535
- <div class="o-flex"><span class="o-faint o-xs">Markdown works.</span><button type="button" class="o-btn o-sm o-r" (click)="drafting.set('')">Cancel</button><button type="button" class="o-btn o-sm o-dark" [disabled]="!draftBody().trim()" (click)="send(l)" data-testid="line-comment-add">Comment</button></div>
536
- </div></div>
625
+ } @empty {<div class="o-empty o-sm full">{{ ignoreSpace() ? 'Only whitespace changed.' : 'No changes to show.' }}</div>}
626
+ </div>
627
+ } @else {
628
+ <div class="o-code" style="overflow-x:auto" role="table" [attr.aria-label]="'Changes to ' + file().path">
629
+ @for (h of d.hunks; track $index) {
630
+ <div class="hunk o-mono" role="row">{{ h.header }}</div>
631
+ @for (l of h.lines; track $index) {
632
+ <div class="o-ln" role="row" [class.o-add]="l.kind === 'add'" [class.o-del]="l.kind === 'delete'">
633
+ <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.kind === 'delete' ? l.old : l.new }}</span>
634
+ <span class="o-s" aria-hidden="true">{{ l.kind === 'add' ? '+' : l.kind === 'delete' ? '−' : ' ' }}</span>
635
+ <span class="o-sr-only">{{ l.kind === 'add' ? 'added' : l.kind === 'delete' ? 'removed' : '' }}</span>
636
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
637
+ </div>
638
+ @for (t of threadsAt(l); track t.id) {<ng-container *ngTemplateOutlet="threadTpl; context: { $implicit: t }" />}
639
+ @if (drafting() === key(l)) {<ng-container *ngTemplateOutlet="draftTpl; context: { $implicit: l }" />}
537
640
  }
641
+ } @empty {
642
+ <div class="o-empty o-sm">{{ ignoreSpace() ? 'Only whitespace changed.' : 'No changes to show.' }}</div>
538
643
  }
539
- } @empty {
540
- <div class="o-empty o-sm">No changes to show.</div>
541
- }
542
- </div>
644
+ </div>
645
+ }
543
646
  } @else {
544
647
  <div class="o-empty o-sm">Loading…</div>
545
648
  }
546
649
  }
547
650
  </div>
548
- `, isInline: true, styles: [":host ::ng-deep [class^=tk-k]{color:var(--o-tk-k)}:host ::ng-deep [class^=tk-s],:host ::ng-deep [class^=tk-l]{color:var(--o-tk-s)}:host ::ng-deep [class^=tk-c]{color:var(--o-tk-c);font-style:italic}:host ::ng-deep [class^=tk-m]{color:var(--o-tk-n)}:host ::ng-deep .tk-nf,:host ::ng-deep .tk-nx,:host ::ng-deep .tk-fm{color:var(--o-tk-f)}:host ::ng-deep .tk-nb,:host ::ng-deep .tk-nt,:host ::ng-deep .tk-bp{color:var(--o-tk-p)}:host ::ng-deep .tk-gi{color:var(--o-add)}:host ::ng-deep .tk-gd{color:var(--o-bad-tx)}\n", ".o-ln .o-n{cursor:default;width:44px}.o-ln .o-n.cmt{cursor:pointer}.o-ln .o-n.cmt:hover{color:var(--o-acc-tx)}.hunk{color:var(--o-dim);background:var(--o-well);padding:2px 12px;font-size:11px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
651
+
652
+ <ng-template #threadTpl let-t>
653
+ <div class="o-thread" [attr.data-testid]="'thread-' + t.id">
654
+ <div class="o-cb o-col" style="gap:6px;padding:10px 12px">
655
+ @for (c of t.comments; track c.id) {
656
+ <span class="o-sm"><b>{{ people().name(c.authorId) }}</b><span class="o-faint"> · {{ ago(c.createdAt) }}</span>
657
+ @if (c.pending) {<span class="o-chip o-amb" style="margin-left:6px">Pending</span>
658
+ @if (c.authorId === me()) {<button type="button" class="o-lnk" style="margin-left:6px;color:var(--o-mut)" (click)="discard.emit(c.id)" [attr.aria-label]="'Discard pending comment: ' + c.body">Discard</button>}
659
+ }
660
+ </span>
661
+ <span style="white-space:pre-wrap">{{ c.body }}</span>
662
+ }
663
+ </div>
664
+ @if (canComment()) {
665
+ <div class="o-cf">
666
+ <input class="o-in o-sm" placeholder="Reply…" aria-label="Reply" [ngModel]="replies()[t.id] ?? ''" (ngModelChange)="setReply(t.id, $event)" (keydown.enter)="sendReply(t.id)" />
667
+ <button type="button" class="o-btn o-sm" (click)="sendReply(t.id)">Reply</button>
668
+ <button type="button" class="o-btn o-sm" (click)="resolve.emit({ threadId: t.id, resolved: !t.resolved })">{{ t.resolved ? 'Unresolve' : 'Resolve' }}</button>
669
+ </div>
670
+ }
671
+ </div>
672
+ </ng-template>
673
+
674
+ <ng-template #draftTpl let-l>
675
+ <div class="o-thread"><div class="o-cb o-col" style="gap:8px;padding:10px 12px">
676
+ <textarea class="o-in" rows="2" [placeholder]="'Leave a comment on line ' + lineOf(l)" [attr.aria-label]="'Comment on line ' + lineOf(l)" [ngModel]="draftBody()" (ngModelChange)="draftBody.set($event)" data-testid="line-comment"></textarea>
677
+ <div class="o-flex o-wrap"><span class="o-faint o-xs">Markdown works.</span>
678
+ <button type="button" class="o-btn o-sm o-r" (click)="drafting.set('')">Cancel</button>
679
+ <button type="button" class="o-btn o-sm" [disabled]="!draftBody().trim()" (click)="send(l, false)" data-testid="line-comment-add">Add single comment</button>
680
+ <button type="button" class="o-btn o-sm o-pri" [disabled]="!draftBody().trim()" (click)="send(l, true)" data-testid="line-comment-review">{{ reviewing() ? 'Add review comment' : 'Start a review' }}</button>
681
+ </div>
682
+ </div></div>
683
+ </ng-template>
684
+ `, isInline: true, styles: [":host ::ng-deep [class^=tk-k]{color:var(--o-tk-k)}:host ::ng-deep [class^=tk-s],:host ::ng-deep [class^=tk-l]{color:var(--o-tk-s)}:host ::ng-deep [class^=tk-c]{color:var(--o-tk-c);font-style:italic}:host ::ng-deep [class^=tk-m]{color:var(--o-tk-n)}:host ::ng-deep .tk-nf,:host ::ng-deep .tk-nx,:host ::ng-deep .tk-fm{color:var(--o-tk-f)}:host ::ng-deep .tk-nb,:host ::ng-deep .tk-nt,:host ::ng-deep .tk-bp{color:var(--o-tk-p)}:host ::ng-deep .tk-gi{color:var(--o-add)}:host ::ng-deep .tk-gd{color:var(--o-bad-tx)}\n", ".o-ln .o-n{cursor:default;width:44px}.o-ln .o-n.cmt{cursor:pointer}.o-ln .o-n.cmt:hover{color:var(--o-acc-tx)}.hunk{color:var(--o-dim);background:var(--o-well);padding:2px 12px;font-size:11px}.split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.split .side-l{border-right:1px solid var(--o-line-soft)}.split .full{grid-column:1 / -1}.split .o-ln .o-t{white-space:pre-wrap;word-break:break-all}.empty-side{background:var(--o-well)}.viewed{display:inline-flex;align-items:center;gap:6px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
549
685
  }
550
686
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: DiffView, decorators: [{
551
687
  type: Component,
552
- args: [{ selector: 'o-diff', imports: [FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
553
- <div class="o-card" [attr.data-testid]="'diff-' + file().path">
688
+ args: [{ selector: 'o-diff', imports: [FormsModule, NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, template: `
689
+ <div class="o-card" [attr.id]="'file-' + file().path" [attr.data-testid]="'diff-' + file().path">
554
690
  <div class="o-ch" style="background:var(--o-well)">
555
- <button type="button" class="o-btn o-ghost o-xs" (click)="open.set(!open())" [attr.aria-expanded]="open()" [attr.aria-label]="(open() ? 'Collapse ' : 'Expand ') + file().path">{{ open() ? '▾' : '▸' }}</button>
691
+ <button type="button" class="o-btn o-ghost o-xs" (click)="toggleOpen()" [attr.aria-expanded]="open()" [attr.aria-label]="(open() ? 'Collapse ' : 'Expand ') + file().path">{{ open() ? '▾' : '▸' }}</button>
556
692
  <span class="o-mono o-sm o-ell">{{ file().oldPath && file().oldPath !== file().path ? file().oldPath + ' → ' : '' }}{{ file().path }}</span>
557
693
  <span class="o-grn-t o-sm">+{{ file().additions }}</span><span class="o-red-t o-sm">−{{ file().deletions }}</span>
558
694
  @if (threadsHere().length) {<span class="o-faint o-sm">💬 {{ threadsHere().length }}</span>}
559
- <span class="o-r"><ng-content /></span>
695
+ <span class="o-r o-flex" style="gap:6px">
696
+ @if (open() && diff() && context() < 100 && !file().binary) {<button type="button" class="o-btn o-xs" (click)="moreContext()" [attr.aria-label]="'Show more context in ' + file().path">More context</button>}
697
+ <ng-content />
698
+ @if (viewable()) {
699
+ <button type="button" class="o-btn o-xs viewed" role="checkbox" [attr.aria-checked]="viewed()" [attr.aria-label]="'Viewed ' + file().path" (click)="viewedChange.emit(!viewed())"><span class="o-cbx" [class.o-on]="viewed()" style="width:12px;height:12px"></span>Viewed</button>
700
+ }
701
+ </span>
560
702
  </div>
561
703
  @if (open()) {
562
704
  @if (file().binary) {
563
705
  <div class="o-empty o-sm">A binary file.</div>
706
+ } @else if (waiting()) {
707
+ <div class="o-empty o-sm o-col" style="gap:8px;align-items:center">
708
+ <span>{{ file().additions + file().deletions }} changed lines.</span>
709
+ <button type="button" class="o-btn o-sm" (click)="waiting.set(false); load()">Load diff</button>
710
+ </div>
564
711
  } @else if (error()) {
565
712
  <div class="o-note o-red" role="alert">{{ error() }}</div>
566
713
  } @else if (diff(); as d) {
567
714
  @if (d.truncated) {<div class="o-note">This diff is too large to show in full.</div>}
568
- <div class="o-code" style="overflow-x:auto" role="table" [attr.aria-label]="'Changes to ' + file().path">
569
- @for (h of d.hunks; track $index) {
570
- <div class="hunk o-mono" role="row">{{ h.header }}</div>
571
- @for (l of h.lines; track $index) {
572
- <div class="o-ln" role="row" [class.o-add]="l.kind === 'add'" [class.o-del]="l.kind === 'delete'">
573
- <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.kind === 'delete' ? l.old : l.new }}</span>
574
- <span class="o-s" aria-hidden="true">{{ l.kind === 'add' ? '+' : l.kind === 'delete' ? '−' : ' ' }}</span>
575
- <span class="o-sr-only">{{ l.kind === 'add' ? 'added' : l.kind === 'delete' ? 'removed' : '' }}</span>
576
- <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
577
- </div>
578
- @for (t of threadsAt(l); track t.id) {
579
- <div class="o-thread" [attr.data-testid]="'thread-' + t.id">
580
- <div class="o-cb o-col" style="gap:6px;padding:10px 12px">
581
- @for (c of t.comments; track c.id) {
582
- <span class="o-sm"><b>{{ people().name(c.authorId) }}</b><span class="o-faint"> · {{ ago(c.createdAt) }}{{ c.pending ? ' · pending' : '' }}</span></span>
583
- <span style="white-space:pre-wrap">{{ c.body }}</span>
584
- }
715
+ @if (mode() === 'split') {
716
+ <div class="o-code split" style="overflow-x:auto" role="table" [attr.aria-label]="'Changes to ' + file().path + ', side by side'">
717
+ @for (h of d.hunks; track $index; let hi = $index) {
718
+ <div class="hunk o-mono full" role="row">{{ h.header }}</div>
719
+ @for (r of splitRows()[hi]; track $index) {
720
+ @if (r.left; as l) {
721
+ <div class="o-ln side-l" role="row" [class.o-del]="l.kind === 'delete'" [attr.data-testid]="'split-old-' + l.old">
722
+ <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.old }}</span>
723
+ <span class="o-sr-only">{{ l.kind === 'delete' ? 'removed' : '' }}</span>
724
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
585
725
  </div>
586
- @if (canComment()) {
587
- <div class="o-cf">
588
- <input class="o-in o-sm" placeholder="Reply…" aria-label="Reply" [ngModel]="replies()[t.id] ?? ''" (ngModelChange)="setReply(t.id, $event)" (keydown.enter)="sendReply(t.id)" />
589
- <button type="button" class="o-btn o-sm" (click)="sendReply(t.id)">Reply</button>
590
- <button type="button" class="o-btn o-sm" (click)="resolve.emit({ threadId: t.id, resolved: !t.resolved })">{{ t.resolved ? 'Unresolve' : 'Resolve' }}</button>
591
- </div>
592
- }
593
- </div>
726
+ } @else {<div class="o-ln side-l empty-side" aria-hidden="true"><span class="o-n"></span><span class="o-t"></span></div>}
727
+ @if (r.right; as l) {
728
+ <div class="o-ln" role="row" [class.o-add]="l.kind === 'add'" [attr.data-testid]="'split-new-' + l.new">
729
+ <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.new }}</span>
730
+ <span class="o-sr-only">{{ l.kind === 'add' ? 'added' : '' }}</span>
731
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
732
+ </div>
733
+ } @else {<div class="o-ln empty-side" aria-hidden="true"><span class="o-n"></span><span class="o-t"></span></div>}
734
+ @for (l of rowLines(r); track $index) {
735
+ @for (t of threadsAt(l); track t.id) {<div class="full"><ng-container *ngTemplateOutlet="threadTpl; context: { $implicit: t }" /></div>}
736
+ @if (drafting() === key(l)) {<div class="full"><ng-container *ngTemplateOutlet="draftTpl; context: { $implicit: l }" /></div>}
737
+ }
594
738
  }
595
- @if (drafting() === key(l)) {
596
- <div class="o-thread"><div class="o-cb o-col" style="gap:8px;padding:10px 12px">
597
- <textarea class="o-in" rows="2" [placeholder]="'Leave a comment on line ' + lineOf(l)" [attr.aria-label]="'Comment on line ' + lineOf(l)" [ngModel]="draftBody()" (ngModelChange)="draftBody.set($event)" data-testid="line-comment"></textarea>
598
- <div class="o-flex"><span class="o-faint o-xs">Markdown works.</span><button type="button" class="o-btn o-sm o-r" (click)="drafting.set('')">Cancel</button><button type="button" class="o-btn o-sm o-dark" [disabled]="!draftBody().trim()" (click)="send(l)" data-testid="line-comment-add">Comment</button></div>
599
- </div></div>
739
+ } @empty {<div class="o-empty o-sm full">{{ ignoreSpace() ? 'Only whitespace changed.' : 'No changes to show.' }}</div>}
740
+ </div>
741
+ } @else {
742
+ <div class="o-code" style="overflow-x:auto" role="table" [attr.aria-label]="'Changes to ' + file().path">
743
+ @for (h of d.hunks; track $index) {
744
+ <div class="hunk o-mono" role="row">{{ h.header }}</div>
745
+ @for (l of h.lines; track $index) {
746
+ <div class="o-ln" role="row" [class.o-add]="l.kind === 'add'" [class.o-del]="l.kind === 'delete'">
747
+ <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.kind === 'delete' ? l.old : l.new }}</span>
748
+ <span class="o-s" aria-hidden="true">{{ l.kind === 'add' ? '+' : l.kind === 'delete' ? '−' : ' ' }}</span>
749
+ <span class="o-sr-only">{{ l.kind === 'add' ? 'added' : l.kind === 'delete' ? 'removed' : '' }}</span>
750
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
751
+ </div>
752
+ @for (t of threadsAt(l); track t.id) {<ng-container *ngTemplateOutlet="threadTpl; context: { $implicit: t }" />}
753
+ @if (drafting() === key(l)) {<ng-container *ngTemplateOutlet="draftTpl; context: { $implicit: l }" />}
600
754
  }
755
+ } @empty {
756
+ <div class="o-empty o-sm">{{ ignoreSpace() ? 'Only whitespace changed.' : 'No changes to show.' }}</div>
601
757
  }
602
- } @empty {
603
- <div class="o-empty o-sm">No changes to show.</div>
604
- }
605
- </div>
758
+ </div>
759
+ }
606
760
  } @else {
607
761
  <div class="o-empty o-sm">Loading…</div>
608
762
  }
609
763
  }
610
764
  </div>
611
- `, styles: [":host ::ng-deep [class^=tk-k]{color:var(--o-tk-k)}:host ::ng-deep [class^=tk-s],:host ::ng-deep [class^=tk-l]{color:var(--o-tk-s)}:host ::ng-deep [class^=tk-c]{color:var(--o-tk-c);font-style:italic}:host ::ng-deep [class^=tk-m]{color:var(--o-tk-n)}:host ::ng-deep .tk-nf,:host ::ng-deep .tk-nx,:host ::ng-deep .tk-fm{color:var(--o-tk-f)}:host ::ng-deep .tk-nb,:host ::ng-deep .tk-nt,:host ::ng-deep .tk-bp{color:var(--o-tk-p)}:host ::ng-deep .tk-gi{color:var(--o-add)}:host ::ng-deep .tk-gd{color:var(--o-bad-tx)}\n", ".o-ln .o-n{cursor:default;width:44px}.o-ln .o-n.cmt{cursor:pointer}.o-ln .o-n.cmt:hover{color:var(--o-acc-tx)}.hunk{color:var(--o-dim);background:var(--o-well);padding:2px 12px;font-size:11px}\n"] }]
612
- }], ctorParameters: () => [], propDecorators: { repo: [{ type: i0.Input, args: [{ isSignal: true, alias: "repo", required: true }] }], file: [{ type: i0.Input, args: [{ isSignal: true, alias: "file", required: true }] }], head: [{ type: i0.Input, args: [{ isSignal: true, alias: "head", required: true }] }], base: [{ type: i0.Input, args: [{ isSignal: true, alias: "base", required: false }] }], threads: [{ type: i0.Input, args: [{ isSignal: true, alias: "threads", required: false }] }], canComment: [{ type: i0.Input, args: [{ isSignal: true, alias: "canComment", required: false }] }], comment: [{ type: i0.Output, args: ["comment"] }], reply: [{ type: i0.Output, args: ["reply"] }], resolve: [{ type: i0.Output, args: ["resolve"] }] } });
765
+
766
+ <ng-template #threadTpl let-t>
767
+ <div class="o-thread" [attr.data-testid]="'thread-' + t.id">
768
+ <div class="o-cb o-col" style="gap:6px;padding:10px 12px">
769
+ @for (c of t.comments; track c.id) {
770
+ <span class="o-sm"><b>{{ people().name(c.authorId) }}</b><span class="o-faint"> · {{ ago(c.createdAt) }}</span>
771
+ @if (c.pending) {<span class="o-chip o-amb" style="margin-left:6px">Pending</span>
772
+ @if (c.authorId === me()) {<button type="button" class="o-lnk" style="margin-left:6px;color:var(--o-mut)" (click)="discard.emit(c.id)" [attr.aria-label]="'Discard pending comment: ' + c.body">Discard</button>}
773
+ }
774
+ </span>
775
+ <span style="white-space:pre-wrap">{{ c.body }}</span>
776
+ }
777
+ </div>
778
+ @if (canComment()) {
779
+ <div class="o-cf">
780
+ <input class="o-in o-sm" placeholder="Reply…" aria-label="Reply" [ngModel]="replies()[t.id] ?? ''" (ngModelChange)="setReply(t.id, $event)" (keydown.enter)="sendReply(t.id)" />
781
+ <button type="button" class="o-btn o-sm" (click)="sendReply(t.id)">Reply</button>
782
+ <button type="button" class="o-btn o-sm" (click)="resolve.emit({ threadId: t.id, resolved: !t.resolved })">{{ t.resolved ? 'Unresolve' : 'Resolve' }}</button>
783
+ </div>
784
+ }
785
+ </div>
786
+ </ng-template>
787
+
788
+ <ng-template #draftTpl let-l>
789
+ <div class="o-thread"><div class="o-cb o-col" style="gap:8px;padding:10px 12px">
790
+ <textarea class="o-in" rows="2" [placeholder]="'Leave a comment on line ' + lineOf(l)" [attr.aria-label]="'Comment on line ' + lineOf(l)" [ngModel]="draftBody()" (ngModelChange)="draftBody.set($event)" data-testid="line-comment"></textarea>
791
+ <div class="o-flex o-wrap"><span class="o-faint o-xs">Markdown works.</span>
792
+ <button type="button" class="o-btn o-sm o-r" (click)="drafting.set('')">Cancel</button>
793
+ <button type="button" class="o-btn o-sm" [disabled]="!draftBody().trim()" (click)="send(l, false)" data-testid="line-comment-add">Add single comment</button>
794
+ <button type="button" class="o-btn o-sm o-pri" [disabled]="!draftBody().trim()" (click)="send(l, true)" data-testid="line-comment-review">{{ reviewing() ? 'Add review comment' : 'Start a review' }}</button>
795
+ </div>
796
+ </div></div>
797
+ </ng-template>
798
+ `, styles: [":host ::ng-deep [class^=tk-k]{color:var(--o-tk-k)}:host ::ng-deep [class^=tk-s],:host ::ng-deep [class^=tk-l]{color:var(--o-tk-s)}:host ::ng-deep [class^=tk-c]{color:var(--o-tk-c);font-style:italic}:host ::ng-deep [class^=tk-m]{color:var(--o-tk-n)}:host ::ng-deep .tk-nf,:host ::ng-deep .tk-nx,:host ::ng-deep .tk-fm{color:var(--o-tk-f)}:host ::ng-deep .tk-nb,:host ::ng-deep .tk-nt,:host ::ng-deep .tk-bp{color:var(--o-tk-p)}:host ::ng-deep .tk-gi{color:var(--o-add)}:host ::ng-deep .tk-gd{color:var(--o-bad-tx)}\n", ".o-ln .o-n{cursor:default;width:44px}.o-ln .o-n.cmt{cursor:pointer}.o-ln .o-n.cmt:hover{color:var(--o-acc-tx)}.hunk{color:var(--o-dim);background:var(--o-well);padding:2px 12px;font-size:11px}.split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.split .side-l{border-right:1px solid var(--o-line-soft)}.split .full{grid-column:1 / -1}.split .o-ln .o-t{white-space:pre-wrap;word-break:break-all}.empty-side{background:var(--o-well)}.viewed{display:inline-flex;align-items:center;gap:6px}\n"] }]
799
+ }], ctorParameters: () => [], propDecorators: { repo: [{ type: i0.Input, args: [{ isSignal: true, alias: "repo", required: true }] }], file: [{ type: i0.Input, args: [{ isSignal: true, alias: "file", required: true }] }], head: [{ type: i0.Input, args: [{ isSignal: true, alias: "head", required: true }] }], base: [{ type: i0.Input, args: [{ isSignal: true, alias: "base", required: false }] }], threads: [{ type: i0.Input, args: [{ isSignal: true, alias: "threads", required: false }] }], canComment: [{ type: i0.Input, args: [{ isSignal: true, alias: "canComment", required: false }] }], mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], ignoreSpace: [{ type: i0.Input, args: [{ isSignal: true, alias: "ignoreSpace", required: false }] }], viewed: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewed", required: false }] }], reviewing: [{ type: i0.Input, args: [{ isSignal: true, alias: "reviewing", required: false }] }], comment: [{ type: i0.Output, args: ["comment"] }], reply: [{ type: i0.Output, args: ["reply"] }], resolve: [{ type: i0.Output, args: ["resolve"] }], discard: [{ type: i0.Output, args: ["discard"] }], viewedChange: [{ type: i0.Output, args: ["viewedChange"] }] } });
800
+ /** Pairs a hunk's lines side by side: context on both sides, a run of removals beside the run of additions that follows it. */
801
+ function pair(lines) {
802
+ const rows = [];
803
+ let i = 0;
804
+ while (i < lines.length) {
805
+ const l = lines[i];
806
+ if (l.kind === 'context') {
807
+ rows.push({ left: l, right: l });
808
+ i++;
809
+ continue;
810
+ }
811
+ const dels = [];
812
+ const adds = [];
813
+ while (i < lines.length && lines[i].kind === 'delete')
814
+ dels.push(lines[i++]);
815
+ while (i < lines.length && lines[i].kind === 'add')
816
+ adds.push(lines[i++]);
817
+ for (let k = 0; k < Math.max(dels.length, adds.length); k++)
818
+ rows.push({ left: dels[k], right: adds[k] });
819
+ }
820
+ return rows;
821
+ }
613
822
 
614
823
  // /{org}/{project}/code (prototype "code_repos"): the project's repositories.
615
824
  class ReposPage {
@@ -1686,7 +1895,7 @@ class CommitPage extends RepoPage {
1686
1895
  <div class="o-card"><div class="o-empty">Loading…</div></div>
1687
1896
  }
1688
1897
  </div>
1689
- `, isInline: true, dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: DiffView, selector: "o-diff", inputs: ["repo", "file", "head", "base", "threads", "canComment"], outputs: ["comment", "reply", "resolve"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1898
+ `, isInline: true, dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: DiffView, selector: "o-diff", inputs: ["repo", "file", "head", "base", "threads", "canComment", "mode", "ignoreSpace", "viewed", "reviewing"], outputs: ["comment", "reply", "resolve", "discard", "viewedChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1690
1899
  }
1691
1900
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: CommitPage, decorators: [{
1692
1901
  type: Component,
@@ -2176,6 +2385,17 @@ class PullPage extends RepoPage {
2176
2385
  this.initials = initials;
2177
2386
  this.dot = checkDot;
2178
2387
  this.threads = computed(() => this.timeline().threads, ...(ngDevMode ? [{ debugName: "threads" }] : /* istanbul ignore next */ []));
2388
+ this.mode = signal(readPref('origine.diff.mode') === 'split' ? 'split' : 'unified', ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
2389
+ this.hideWs = signal(false, ...(ngDevMode ? [{ debugName: "hideWs" }] : /* istanbul ignore next */ []));
2390
+ /** Files marked viewed, kept per pull request head in this browser (a new push resets them). */
2391
+ this.viewed = signal({}, ...(ngDevMode ? [{ debugName: "viewed" }] : /* istanbul ignore next */ []));
2392
+ this.viewedCount = computed(() => (this.cmp()?.files ?? []).filter((f) => this.viewed()[f.path]).length, ...(ngDevMode ? [{ debugName: "viewedCount" }] : /* istanbul ignore next */ []));
2393
+ /** The caller's pending review comments (shown only to them until the review is submitted). */
2394
+ this.myPending = computed(() => {
2395
+ const me = this.host.meId();
2396
+ const t = this.timeline();
2397
+ return [...t.comments, ...t.threads.flatMap((th) => th.comments)].filter((c, i, all) => c.pending && c.authorId === me && all.findIndex((x) => x.id === c.id) === i);
2398
+ }, ...(ngDevMode ? [{ debugName: "myPending" }] : /* istanbul ignore next */ []));
2179
2399
  this.openThreads = computed(() => this.threads().filter((t) => !t.resolved && !t.outdated).length, ...(ngDevMode ? [{ debugName: "openThreads" }] : /* istanbul ignore next */ []));
2180
2400
  this.tabs = computed(() => {
2181
2401
  const p = this.pr();
@@ -2194,8 +2414,12 @@ class PullPage extends RepoPage {
2194
2414
  this.approvedByMe = computed(() => (this.pr()?.reviews ?? []).some((r) => r.authorId === this.host.meId() && r.state === 'approved' && !r.stale), ...(ngDevMode ? [{ debugName: "approvedByMe" }] : /* istanbul ignore next */ []));
2195
2415
  this.reviewKinds = computed(() => {
2196
2416
  const need = this.pr()?.gate.rows.find((r) => /required by branch protection|requires/.test(r.text + (r.meta ?? '')));
2417
+ const comment = { id: 'commented', label: 'Comment', hint: 'General feedback without approving.' };
2418
+ // the author (or a role that cannot push) still posts their pending comments as a review
2419
+ if (!this.pr()?.canReview)
2420
+ return [{ ...comment, hint: 'Post your pending comments.' }];
2197
2421
  return [
2198
- { id: 'commented', label: 'Comment', hint: 'General feedback without approving.' },
2422
+ comment,
2199
2423
  { id: 'approved', label: 'Approve', hint: need ? `Counts toward the approvals ${this.pr()?.target} requires.` : 'Approve these changes.' },
2200
2424
  { id: 'changes_requested', label: 'Request changes', hint: 'Blocks merge until you approve.' },
2201
2425
  ];
@@ -2271,7 +2495,32 @@ class PullPage extends RepoPage {
2271
2495
  this.run(this.api.comment(this.projectId(), this.name(), this.number, { body }), 'Could not add the comment.', () => this.draft.set(''));
2272
2496
  }
2273
2497
  lineComment(c) {
2274
- this.run(this.api.comment(this.projectId(), this.name(), this.number, { body: c.body, path: c.path, side: c.side, line: c.line }), 'Could not add the comment.');
2498
+ this.run(this.api.comment(this.projectId(), this.name(), this.number, { body: c.body, path: c.path, side: c.side, line: c.line, pending: c.pending }), 'Could not add the comment.', () => { if (c.pending)
2499
+ this.note.set('Added to your review. Submit it from Review changes to post your comments.'); });
2500
+ }
2501
+ discard(id) {
2502
+ this.run(this.api.discardComment(this.projectId(), this.name(), this.number, id), 'Could not discard the comment.');
2503
+ }
2504
+ setMode(m) {
2505
+ this.mode.set(m);
2506
+ writePref('origine.diff.mode', m);
2507
+ }
2508
+ viewedKey() {
2509
+ return `origine.viewed.${this.projectId()}.${this.name()}.${this.number}.${this.pr()?.headSha ?? ''}`;
2510
+ }
2511
+ isViewed(path) {
2512
+ return !!this.viewed()[path];
2513
+ }
2514
+ setViewed(path, on) {
2515
+ const v = { ...this.viewed(), [path]: on };
2516
+ if (!on)
2517
+ delete v[path];
2518
+ this.viewed.set(v);
2519
+ writePref(this.viewedKey(), JSON.stringify(v));
2520
+ }
2521
+ jump(path) {
2522
+ const el = this.el.nativeElement.querySelector(`[id="file-${CSS.escape(path)}"]`);
2523
+ el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
2275
2524
  }
2276
2525
  reply(r) {
2277
2526
  this.run(this.api.comment(this.projectId(), this.name(), this.number, { body: r.body, threadId: r.threadId }), 'Could not reply.');
@@ -2310,7 +2559,16 @@ class PullPage extends RepoPage {
2310
2559
  const project = this.projectId();
2311
2560
  this.api.pull(project, this.name(), this.number).subscribe({
2312
2561
  next: (p) => {
2562
+ const headChanged = this.pr()?.headSha !== p.headSha;
2313
2563
  this.pr.set(p);
2564
+ if (headChanged) {
2565
+ try {
2566
+ this.viewed.set(JSON.parse(readPref(this.viewedKey()) || '{}'));
2567
+ }
2568
+ catch {
2569
+ this.viewed.set({});
2570
+ }
2571
+ }
2314
2572
  if (first) {
2315
2573
  this.method.set(p.mergeMethods[0] ?? 'merge');
2316
2574
  this.delBranch.set(p.deleteBranchOnMerge);
@@ -2341,17 +2599,18 @@ class PullPage extends RepoPage {
2341
2599
  <div class="o-tabs" role="tablist">
2342
2600
  @for (t of tabs(); track t.id) {<button type="button" role="tab" class="o-tab" [class.o-on]="tab() === t.id" [attr.aria-selected]="tab() === t.id" (click)="pick(t.id)">{{ t.label }}@if (t.n !== undefined) {<span class="o-n">{{ t.n }}</span>}</button>}
2343
2601
  <span class="o-r"></span>
2344
- @if (p.state === 'open' && p.canReview) {
2602
+ @if (p.state === 'open' && (p.canReview || myPending().length)) {
2345
2603
  <div class="anchor">
2346
2604
  <button type="button" class="o-btn o-sm o-pri" (click)="reviewOpen.set(!reviewOpen())" [attr.aria-expanded]="reviewOpen()" aria-haspopup="dialog" data-testid="review-open">Review changes ⌄</button>
2347
2605
  @if (reviewOpen()) {
2348
2606
  <div class="o-menu" role="dialog" aria-label="Finish your review" style="right:0;top:36px;width:380px;padding:14px;display:flex;flex-direction:column;gap:10px">
2349
2607
  <b>Finish your review</b>
2608
+ <span class="o-faint o-sm" data-testid="review-pending-note">{{ myPending().length ? myPending().length + ' pending ' + (myPending().length === 1 ? 'comment' : 'comments') + ' included' : 'No pending comments' }}</span>
2350
2609
  <textarea class="o-in" rows="3" placeholder="Leave a comment" aria-label="Review comment" style="height:auto;padding:8px 10px" [ngModel]="revBody()" (ngModelChange)="revBody.set($event)"></textarea>
2351
2610
  @for (k of reviewKinds(); track k.id) {
2352
2611
  <button type="button" class="o-opt" style="padding:8px 10px" [class.o-on]="revKind() === k.id" (click)="revKind.set(k.id)"><span class="o-rad" [class.o-on]="revKind() === k.id"></span><span class="o-col" style="gap:1px"><b class="o-b5">{{ k.label }}</b><span class="o-faint o-sm">{{ k.hint }}</span></span></button>
2353
2612
  }
2354
- <div class="o-flex"><button type="button" class="o-btn o-sm o-pri o-r" (click)="submitReview()" [disabled]="busy() || (revKind() !== 'approved' && !revBody().trim())" data-testid="review-submit">Submit review</button></div>
2613
+ <div class="o-flex"><button type="button" class="o-btn o-sm o-pri o-r" (click)="submitReview()" [disabled]="busy() || (revKind() !== 'approved' && !revBody().trim() && !(revKind() === 'commented' && myPending().length))" data-testid="review-submit">Submit review</button></div>
2355
2614
  </div>
2356
2615
  }
2357
2616
  </div>
@@ -2435,10 +2694,28 @@ class PullPage extends RepoPage {
2435
2694
  </div>
2436
2695
  }
2437
2696
  @case ('files') {
2438
- <div class="o-flex o-wrap"><span class="o-faint o-sm">{{ cmp()?.files?.length ?? 0 }} {{ cmp()?.files?.length === 1 ? 'file' : 'files' }} · click a line number to comment</span><span class="o-r o-faint o-sm">{{ openThreads() }} unresolved {{ openThreads() === 1 ? 'conversation' : 'conversations' }}</span></div>
2439
- @for (f of cmp()?.files ?? []; track f.path) {
2440
- <o-diff [repo]="name()" [file]="f" [head]="p.headSha" [base]="p.mergeBase" [threads]="threads()" [canComment]="p.state === 'open'" (comment)="lineComment($event)" (reply)="reply($event)" (resolve)="resolve($event)" />
2441
- } @empty {<div class="o-card"><div class="o-empty">{{ cmp() ? 'No changes.' : 'Loading…' }}</div></div>}
2697
+ <div class="o-flex o-wrap">
2698
+ <div class="o-seg" role="group" aria-label="Diff view"><button type="button" [class.o-on]="mode() === 'unified'" [attr.aria-pressed]="mode() === 'unified'" (click)="setMode('unified')">Unified</button><button type="button" [class.o-on]="mode() === 'split'" [attr.aria-pressed]="mode() === 'split'" (click)="setMode('split')" data-testid="diff-split">Split</button></div>
2699
+ <button type="button" class="o-btn o-sm" [class.o-on]="hideWs()" [attr.aria-pressed]="hideWs()" (click)="hideWs.set(!hideWs())" data-testid="hide-ws">Hide whitespace</button>
2700
+ @if (myPending().length) {<span class="o-chip o-amb" data-testid="pending-count">{{ myPending().length }} pending {{ myPending().length === 1 ? 'comment' : 'comments' }}</span>}
2701
+ <span class="o-r o-faint o-sm" data-testid="viewed-note">{{ viewedCount() }} of {{ cmp()?.files?.length ?? 0 }} files viewed · {{ openThreads() }} unresolved {{ openThreads() === 1 ? 'conversation' : 'conversations' }}</span>
2702
+ </div>
2703
+ <div class="files-split">
2704
+ @if ((cmp()?.files?.length ?? 0) > 1) {
2705
+ <div class="o-card files-tree" role="navigation" aria-label="Files">
2706
+ @for (f of cmp()?.files ?? []; track f.path) {
2707
+ <button type="button" class="o-row o-click o-sm" style="width:100%;border:none;background:none;text-align:left" (click)="jump(f.path)"><span class="o-cbx" [class.o-on]="isViewed(f.path)" aria-hidden="true"></span><span class="o-mono o-ell" style="flex:1">{{ f.path }}</span><span class="o-grn-t o-xs">+{{ f.additions }}</span></button>
2708
+ }
2709
+ </div>
2710
+ }
2711
+ <div class="o-col" style="gap:14px;min-width:0">
2712
+ @for (f of cmp()?.files ?? []; track f.path) {
2713
+ <o-diff [repo]="name()" [file]="f" [head]="p.headSha" [base]="p.mergeBase" [threads]="threads()" [canComment]="p.state === 'open'" [mode]="mode()" [ignoreSpace]="hideWs()"
2714
+ [viewed]="isViewed(f.path)" [reviewing]="myPending().length > 0" (viewedChange)="setViewed(f.path, $event)"
2715
+ (comment)="lineComment($event)" (reply)="reply($event)" (resolve)="resolve($event)" (discard)="discard($event)" />
2716
+ } @empty {<div class="o-card"><div class="o-empty">{{ cmp() ? 'No changes.' : 'Loading…' }}</div></div>}
2717
+ </div>
2718
+ </div>
2442
2719
  }
2443
2720
  @case ('checks') {
2444
2721
  <div class="o-card">
@@ -2471,7 +2748,7 @@ class PullPage extends RepoPage {
2471
2748
  <div class="o-card"><div class="o-empty">Loading…</div></div>
2472
2749
  }
2473
2750
  </div>
2474
- `, isInline: true, styles: [".badge{padding:3px 10px;border-radius:12px;font-size:12px;font-weight:500;color:var(--o-acc-fg)}.anchor{position:relative;align-self:center}.merge{display:flex;align-items:center;position:relative}.merge .go{height:32px;padding:0 14px;border-radius:7px 0 0 7px;border:none;font-size:13px;font-weight:500;color:var(--o-acc-fg);background:var(--o-ok);cursor:pointer}.merge .mn{height:32px;padding:0 10px;border:none;border-radius:0 7px 7px 0;font-size:12px;color:var(--o-acc-fg);background:var(--o-ok);border-left:1px solid rgba(255,255,255,.25);cursor:pointer}.merge button:disabled{background:var(--o-line-mid);cursor:not-allowed}.tl{display:flex;flex-direction:column;gap:10px;padding-left:14px;border-left:2px solid var(--o-line);margin-left:14px}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: DiffView, selector: "o-diff", inputs: ["repo", "file", "head", "base", "threads", "canComment"], outputs: ["comment", "reply", "resolve"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
2751
+ `, isInline: true, styles: [".badge{padding:3px 10px;border-radius:12px;font-size:12px;font-weight:500;color:var(--o-acc-fg)}.anchor{position:relative;align-self:center}.merge{display:flex;align-items:center;position:relative}.merge .go{height:32px;padding:0 14px;border-radius:7px 0 0 7px;border:none;font-size:13px;font-weight:500;color:var(--o-acc-fg);background:var(--o-ok);cursor:pointer}.merge .mn{height:32px;padding:0 10px;border:none;border-radius:0 7px 7px 0;font-size:12px;color:var(--o-acc-fg);background:var(--o-ok);border-left:1px solid rgba(255,255,255,.25);cursor:pointer}.merge button:disabled{background:var(--o-line-mid);cursor:not-allowed}.tl{display:flex;flex-direction:column;gap:10px;padding-left:14px;border-left:2px solid var(--o-line);margin-left:14px}.files-split{display:grid;grid-template-columns:210px minmax(0,1fr);gap:14px;align-items:start}.files-split:has(>:only-child){grid-template-columns:minmax(0,1fr)}.files-tree{position:sticky;top:8px;max-height:calc(100vh - 120px);overflow:auto}@media(max-width:900px){.files-split{grid-template-columns:minmax(0,1fr)}.files-tree{display:none}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: DiffView, selector: "o-diff", inputs: ["repo", "file", "head", "base", "threads", "canComment", "mode", "ignoreSpace", "viewed", "reviewing"], outputs: ["comment", "reply", "resolve", "discard", "viewedChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
2475
2752
  }
2476
2753
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: PullPage, decorators: [{
2477
2754
  type: Component,
@@ -2492,17 +2769,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2492
2769
  <div class="o-tabs" role="tablist">
2493
2770
  @for (t of tabs(); track t.id) {<button type="button" role="tab" class="o-tab" [class.o-on]="tab() === t.id" [attr.aria-selected]="tab() === t.id" (click)="pick(t.id)">{{ t.label }}@if (t.n !== undefined) {<span class="o-n">{{ t.n }}</span>}</button>}
2494
2771
  <span class="o-r"></span>
2495
- @if (p.state === 'open' && p.canReview) {
2772
+ @if (p.state === 'open' && (p.canReview || myPending().length)) {
2496
2773
  <div class="anchor">
2497
2774
  <button type="button" class="o-btn o-sm o-pri" (click)="reviewOpen.set(!reviewOpen())" [attr.aria-expanded]="reviewOpen()" aria-haspopup="dialog" data-testid="review-open">Review changes ⌄</button>
2498
2775
  @if (reviewOpen()) {
2499
2776
  <div class="o-menu" role="dialog" aria-label="Finish your review" style="right:0;top:36px;width:380px;padding:14px;display:flex;flex-direction:column;gap:10px">
2500
2777
  <b>Finish your review</b>
2778
+ <span class="o-faint o-sm" data-testid="review-pending-note">{{ myPending().length ? myPending().length + ' pending ' + (myPending().length === 1 ? 'comment' : 'comments') + ' included' : 'No pending comments' }}</span>
2501
2779
  <textarea class="o-in" rows="3" placeholder="Leave a comment" aria-label="Review comment" style="height:auto;padding:8px 10px" [ngModel]="revBody()" (ngModelChange)="revBody.set($event)"></textarea>
2502
2780
  @for (k of reviewKinds(); track k.id) {
2503
2781
  <button type="button" class="o-opt" style="padding:8px 10px" [class.o-on]="revKind() === k.id" (click)="revKind.set(k.id)"><span class="o-rad" [class.o-on]="revKind() === k.id"></span><span class="o-col" style="gap:1px"><b class="o-b5">{{ k.label }}</b><span class="o-faint o-sm">{{ k.hint }}</span></span></button>
2504
2782
  }
2505
- <div class="o-flex"><button type="button" class="o-btn o-sm o-pri o-r" (click)="submitReview()" [disabled]="busy() || (revKind() !== 'approved' && !revBody().trim())" data-testid="review-submit">Submit review</button></div>
2783
+ <div class="o-flex"><button type="button" class="o-btn o-sm o-pri o-r" (click)="submitReview()" [disabled]="busy() || (revKind() !== 'approved' && !revBody().trim() && !(revKind() === 'commented' && myPending().length))" data-testid="review-submit">Submit review</button></div>
2506
2784
  </div>
2507
2785
  }
2508
2786
  </div>
@@ -2586,10 +2864,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2586
2864
  </div>
2587
2865
  }
2588
2866
  @case ('files') {
2589
- <div class="o-flex o-wrap"><span class="o-faint o-sm">{{ cmp()?.files?.length ?? 0 }} {{ cmp()?.files?.length === 1 ? 'file' : 'files' }} · click a line number to comment</span><span class="o-r o-faint o-sm">{{ openThreads() }} unresolved {{ openThreads() === 1 ? 'conversation' : 'conversations' }}</span></div>
2590
- @for (f of cmp()?.files ?? []; track f.path) {
2591
- <o-diff [repo]="name()" [file]="f" [head]="p.headSha" [base]="p.mergeBase" [threads]="threads()" [canComment]="p.state === 'open'" (comment)="lineComment($event)" (reply)="reply($event)" (resolve)="resolve($event)" />
2592
- } @empty {<div class="o-card"><div class="o-empty">{{ cmp() ? 'No changes.' : 'Loading…' }}</div></div>}
2867
+ <div class="o-flex o-wrap">
2868
+ <div class="o-seg" role="group" aria-label="Diff view"><button type="button" [class.o-on]="mode() === 'unified'" [attr.aria-pressed]="mode() === 'unified'" (click)="setMode('unified')">Unified</button><button type="button" [class.o-on]="mode() === 'split'" [attr.aria-pressed]="mode() === 'split'" (click)="setMode('split')" data-testid="diff-split">Split</button></div>
2869
+ <button type="button" class="o-btn o-sm" [class.o-on]="hideWs()" [attr.aria-pressed]="hideWs()" (click)="hideWs.set(!hideWs())" data-testid="hide-ws">Hide whitespace</button>
2870
+ @if (myPending().length) {<span class="o-chip o-amb" data-testid="pending-count">{{ myPending().length }} pending {{ myPending().length === 1 ? 'comment' : 'comments' }}</span>}
2871
+ <span class="o-r o-faint o-sm" data-testid="viewed-note">{{ viewedCount() }} of {{ cmp()?.files?.length ?? 0 }} files viewed · {{ openThreads() }} unresolved {{ openThreads() === 1 ? 'conversation' : 'conversations' }}</span>
2872
+ </div>
2873
+ <div class="files-split">
2874
+ @if ((cmp()?.files?.length ?? 0) > 1) {
2875
+ <div class="o-card files-tree" role="navigation" aria-label="Files">
2876
+ @for (f of cmp()?.files ?? []; track f.path) {
2877
+ <button type="button" class="o-row o-click o-sm" style="width:100%;border:none;background:none;text-align:left" (click)="jump(f.path)"><span class="o-cbx" [class.o-on]="isViewed(f.path)" aria-hidden="true"></span><span class="o-mono o-ell" style="flex:1">{{ f.path }}</span><span class="o-grn-t o-xs">+{{ f.additions }}</span></button>
2878
+ }
2879
+ </div>
2880
+ }
2881
+ <div class="o-col" style="gap:14px;min-width:0">
2882
+ @for (f of cmp()?.files ?? []; track f.path) {
2883
+ <o-diff [repo]="name()" [file]="f" [head]="p.headSha" [base]="p.mergeBase" [threads]="threads()" [canComment]="p.state === 'open'" [mode]="mode()" [ignoreSpace]="hideWs()"
2884
+ [viewed]="isViewed(f.path)" [reviewing]="myPending().length > 0" (viewedChange)="setViewed(f.path, $event)"
2885
+ (comment)="lineComment($event)" (reply)="reply($event)" (resolve)="resolve($event)" (discard)="discard($event)" />
2886
+ } @empty {<div class="o-card"><div class="o-empty">{{ cmp() ? 'No changes.' : 'Loading…' }}</div></div>}
2887
+ </div>
2888
+ </div>
2593
2889
  }
2594
2890
  @case ('checks') {
2595
2891
  <div class="o-card">
@@ -2622,11 +2918,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2622
2918
  <div class="o-card"><div class="o-empty">Loading…</div></div>
2623
2919
  }
2624
2920
  </div>
2625
- `, styles: [".badge{padding:3px 10px;border-radius:12px;font-size:12px;font-weight:500;color:var(--o-acc-fg)}.anchor{position:relative;align-self:center}.merge{display:flex;align-items:center;position:relative}.merge .go{height:32px;padding:0 14px;border-radius:7px 0 0 7px;border:none;font-size:13px;font-weight:500;color:var(--o-acc-fg);background:var(--o-ok);cursor:pointer}.merge .mn{height:32px;padding:0 10px;border:none;border-radius:0 7px 7px 0;font-size:12px;color:var(--o-acc-fg);background:var(--o-ok);border-left:1px solid rgba(255,255,255,.25);cursor:pointer}.merge button:disabled{background:var(--o-line-mid);cursor:not-allowed}.tl{display:flex;flex-direction:column;gap:10px;padding-left:14px;border-left:2px solid var(--o-line);margin-left:14px}\n"] }]
2921
+ `, styles: [".badge{padding:3px 10px;border-radius:12px;font-size:12px;font-weight:500;color:var(--o-acc-fg)}.anchor{position:relative;align-self:center}.merge{display:flex;align-items:center;position:relative}.merge .go{height:32px;padding:0 14px;border-radius:7px 0 0 7px;border:none;font-size:13px;font-weight:500;color:var(--o-acc-fg);background:var(--o-ok);cursor:pointer}.merge .mn{height:32px;padding:0 10px;border:none;border-radius:0 7px 7px 0;font-size:12px;color:var(--o-acc-fg);background:var(--o-ok);border-left:1px solid rgba(255,255,255,.25);cursor:pointer}.merge button:disabled{background:var(--o-line-mid);cursor:not-allowed}.tl{display:flex;flex-direction:column;gap:10px;padding-left:14px;border-left:2px solid var(--o-line);margin-left:14px}.files-split{display:grid;grid-template-columns:210px minmax(0,1fr);gap:14px;align-items:start}.files-split:has(>:only-child){grid-template-columns:minmax(0,1fr)}.files-tree{position:sticky;top:8px;max-height:calc(100vh - 120px);overflow:auto}@media(max-width:900px){.files-split{grid-template-columns:minmax(0,1fr)}.files-tree{display:none}}\n"] }]
2626
2922
  }], ctorParameters: () => [], propDecorators: { outside: [{
2627
2923
  type: HostListener,
2628
2924
  args: ['document:click', ['$event']]
2629
2925
  }] } });
2926
+ // Per-viewer conveniences (the diff view, files viewed); storage can be missing or blocked.
2927
+ function readPref(key) {
2928
+ try {
2929
+ return localStorage.getItem(key) ?? '';
2930
+ }
2931
+ catch {
2932
+ return '';
2933
+ }
2934
+ }
2935
+ function writePref(key, value) {
2936
+ try {
2937
+ localStorage.setItem(key, value);
2938
+ }
2939
+ catch {
2940
+ /* private window or blocked storage: the setting lasts for this page only */
2941
+ }
2942
+ }
2630
2943
 
2631
2944
  const BLANK = { id: '', pattern: '', blockDirectPush: true, blockForcePush: true, blockDeletion: true, requiredApprovals: 1, dismissStaleApprovals: true, requireUpToDate: false, requiredChecks: [] };
2632
2945
  // /{org}/{project}/code/{repo}/settings?section= (prototype "code_settings"): general settings and
@@ -2965,5 +3278,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2965
3278
  * Generated bundle index. Do not edit.
2966
3279
  */
2967
3280
 
2968
- export { BranchesPage, CodeApi, CodeHost, CommitPage, CommitsPage, CurrentRepo, CurrentRepoPage, DiffView, FilePage, NewRepoPage, People, PullPage, PullsPage, RefPicker, RepoHomePage, RepoPage, RepoSettingsPage, ReposPage, TOKEN_CSS, ago, apiMessage, checkDot, copy, dayLabel, initials, langColour, shortDate, size };
3281
+ export { BranchesPage, CodeApi, CodeHost, CommitPage, CommitsPage, CurrentRepo, CurrentRepoPage, DiffView, FilePage, NewRepoPage, People, PullPage, PullsPage, RefPicker, RepoHomePage, RepoPage, RepoSettingsPage, ReposPage, TOKEN_CSS, ago, apiMessage, checkDot, copy, dayLabel, initials, langColour, pair, shortDate, size };
2969
3282
  //# sourceMappingURL=chisquare-tech-origine-ngx-apps-code.mjs.map