@chisquare-tech/origine-ngx-apps-code 0.1.0 → 0.3.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) {
@@ -110,6 +115,42 @@ class CodeApi {
110
115
  p['q'] = q;
111
116
  return this.api.get(`${this.repoPath(project, name)}/refs`, p).pipe(map((r) => data(r) ?? []));
112
117
  }
118
+ createTag(project, name, tag, target) {
119
+ return this.api.post(`${this.repoPath(project, name)}/tags`, { tag, target }).pipe(map((r) => data(r)));
120
+ }
121
+ deleteTag(project, name, tag) {
122
+ return this.api.delete(`${this.repoPath(project, name)}/tags/${enc(tag)}`);
123
+ }
124
+ releases(project, name) {
125
+ return this.api.get(`${this.repoPath(project, name)}/releases`).pipe(map((r) => data(r) ?? []));
126
+ }
127
+ createRelease(project, name, r) {
128
+ return this.api.post(`${this.repoPath(project, name)}/releases`, r).pipe(map((x) => data(x)));
129
+ }
130
+ updateRelease(project, name, id, r) {
131
+ return this.api.patch(`${this.repoPath(project, name)}/releases/${enc(id)}`, r).pipe(map((x) => data(x)));
132
+ }
133
+ deleteRelease(project, name, id, deleteTag) {
134
+ return this.api.delete(`${this.repoPath(project, name)}/releases/${enc(id)}${deleteTag ? '?deleteTag=true' : ''}`);
135
+ }
136
+ /** Notes drafted from the done issues and merged pull requests since the previous release. */
137
+ releaseNotes(project, name, target, tag = '') {
138
+ return this.api.post(`${this.repoPath(project, name)}/releases/notes`, { target, tag: tag || undefined }).pipe(map((r) => data(r)?.notes ?? ''));
139
+ }
140
+ addAsset(project, name, id, file) {
141
+ return this.api
142
+ .post(`${this.repoPath(project, name)}/releases/${enc(id)}/assets?filename=${encodeURIComponent(file.name)}`, null, {
143
+ body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' },
144
+ })
145
+ .pipe(map((r) => data(r)));
146
+ }
147
+ /** A release file's bytes (the download needs the session, so no plain link works). */
148
+ assetBlob(project, name, id, asset) {
149
+ return this.api.get(`${this.repoPath(project, name)}/releases/${enc(id)}/assets/${enc(asset)}`, null, { responseType: 'blob' }).pipe(map((b) => b));
150
+ }
151
+ deleteAsset(project, name, id, asset) {
152
+ return this.api.delete(`${this.repoPath(project, name)}/releases/${enc(id)}/assets/${enc(asset)}`);
153
+ }
113
154
  createBranch(project, name, branch, from) {
114
155
  return this.api.post(`${this.repoPath(project, name)}/branches`, { name: branch, from });
115
156
  }
@@ -153,6 +194,10 @@ class CodeApi {
153
194
  comment(project, name, n, c) {
154
195
  return this.api.post(`${this.repoPath(project, name)}/pulls/${n}/comments`, c).pipe(map((r) => data(r)));
155
196
  }
197
+ /** Discards one of the caller's pending review comments. */
198
+ discardComment(project, name, n, id) {
199
+ return this.api.delete(`${this.repoPath(project, name)}/pulls/${n}/comments/${enc(id)}`);
200
+ }
156
201
  review(project, name, n, state, body = '') {
157
202
  return this.api.post(`${this.repoPath(project, name)}/pulls/${n}/reviews`, { state, body }).pipe(map((r) => data(r)));
158
203
  }
@@ -414,22 +459,41 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
414
459
  args: ['document:click', ['$event']]
415
460
  }] } });
416
461
 
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.
462
+ /** Past this many changed lines a file waits for "Load diff" (DEV-19 large diffs). */
463
+ const LARGE = 600;
464
+ /** Context steps for "Show more context". */
465
+ const CONTEXT = [3, 25, 100];
466
+ // One file of a diff (prototype prDiffs): its header with +/− and Viewed, the hunks highlighted,
467
+ // unified or split, and, on a pull request, the review threads on its lines and a comment form on
468
+ // any line number ("Add single comment" or part of a review). The diff loads when the file scrolls
469
+ // near the screen; a very large one waits to be asked for.
419
470
  class DiffView {
420
471
  constructor() {
421
472
  this.api = inject(CodeApi);
422
473
  this.host = inject(CodeHost);
474
+ this.el = inject((ElementRef));
475
+ this.destroyRef = inject(DestroyRef);
423
476
  this.repo = input.required(...(ngDevMode ? [{ debugName: "repo" }] : /* istanbul ignore next */ []));
424
477
  this.file = input.required(...(ngDevMode ? [{ debugName: "file" }] : /* istanbul ignore next */ []));
425
478
  this.head = input.required(...(ngDevMode ? [{ debugName: "head" }] : /* istanbul ignore next */ []));
426
479
  this.base = input('', ...(ngDevMode ? [{ debugName: "base" }] : /* istanbul ignore next */ []));
427
480
  this.threads = input([], ...(ngDevMode ? [{ debugName: "threads" }] : /* istanbul ignore next */ []));
428
481
  this.canComment = input(false, ...(ngDevMode ? [{ debugName: "canComment" }] : /* istanbul ignore next */ []));
482
+ this.mode = input('unified', ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
483
+ this.ignoreSpace = input(false, ...(ngDevMode ? [{ debugName: "ignoreSpace" }] : /* istanbul ignore next */ []));
484
+ /** The file is marked viewed (pull requests); null hides the Viewed button. */
485
+ this.viewed = input(null, ...(ngDevMode ? [{ debugName: "viewed" }] : /* istanbul ignore next */ []));
486
+ /** The caller has a pending review on this pull request. */
487
+ this.reviewing = input(false, ...(ngDevMode ? [{ debugName: "reviewing" }] : /* istanbul ignore next */ []));
429
488
  this.comment = output();
430
489
  this.reply = output();
431
490
  this.resolve = output();
491
+ this.discard = output();
492
+ this.viewedChange = output();
432
493
  this.open = signal(true, ...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
494
+ this.visible = signal(false, ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
495
+ this.waiting = signal(false, ...(ngDevMode ? [{ debugName: "waiting" }] : /* istanbul ignore next */ []));
496
+ this.context = signal(CONTEXT[0], ...(ngDevMode ? [{ debugName: "context" }] : /* istanbul ignore next */ []));
433
497
  this.diff = signal(null, ...(ngDevMode ? [{ debugName: "diff" }] : /* istanbul ignore next */ []));
434
498
  this.error = signal('', ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
435
499
  this.drafting = signal('', ...(ngDevMode ? [{ debugName: "drafting" }] : /* istanbul ignore next */ []));
@@ -437,25 +501,82 @@ class DiffView {
437
501
  this.replies = signal({}, ...(ngDevMode ? [{ debugName: "replies" }] : /* istanbul ignore next */ []));
438
502
  this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
439
503
  this.threadsHere = computed(() => this.threads().filter((t) => t.path === this.file().path), ...(ngDevMode ? [{ debugName: "threadsHere" }] : /* istanbul ignore next */ []));
504
+ this.viewable = computed(() => this.viewed() !== null, ...(ngDevMode ? [{ debugName: "viewable" }] : /* istanbul ignore next */ []));
505
+ this.me = computed(() => this.host.meId(), ...(ngDevMode ? [{ debugName: "me" }] : /* istanbul ignore next */ []));
506
+ this.splitRows = computed(() => (this.diff()?.hunks ?? []).map((h) => pair(h.lines)), ...(ngDevMode ? [{ debugName: "splitRows" }] : /* istanbul ignore next */ []));
440
507
  this.ago = (s) => ago(s);
508
+ this.asked = false;
509
+ effect(() => {
510
+ // viewed files fold away (and unfold when unmarked)
511
+ const v = this.viewed();
512
+ if (v !== null)
513
+ untracked(() => this.open.set(!v));
514
+ });
441
515
  effect(() => {
442
- const head = this.head();
443
- const base = this.base();
516
+ // what the diff depends on; it loads once the file is near the screen and open
517
+ this.head();
518
+ this.base();
519
+ this.ignoreSpace();
520
+ this.context();
444
521
  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
- }));
522
+ if (!this.visible() || !this.open() || f.binary)
523
+ return;
524
+ untracked(() => {
525
+ if (!this.asked && f.additions + f.deletions > LARGE) {
526
+ this.waiting.set(true);
527
+ return;
528
+ }
529
+ this.load();
530
+ });
451
531
  });
452
532
  }
533
+ ngAfterViewInit() {
534
+ if (typeof IntersectionObserver === 'undefined') {
535
+ this.visible.set(true);
536
+ return;
537
+ }
538
+ const io = new IntersectionObserver((entries) => {
539
+ if (entries.some((e) => e.isIntersecting)) {
540
+ this.visible.set(true);
541
+ io.disconnect();
542
+ }
543
+ }, { rootMargin: '600px 0px' });
544
+ io.observe(this.el.nativeElement);
545
+ this.destroyRef.onDestroy(() => io.disconnect());
546
+ }
547
+ load() {
548
+ const p = this.host.project();
549
+ const f = this.file();
550
+ if (!p || !this.head() || f.binary)
551
+ return;
552
+ this.asked = true;
553
+ this.waiting.set(false);
554
+ this.error.set('');
555
+ this.api.diff(p.id, this.repo(), this.head(), f.path, this.base(), { context: this.context(), ignoreSpace: this.ignoreSpace() }).subscribe({
556
+ next: (d) => this.diff.set(d),
557
+ error: (e) => this.error.set(apiMessage(e, 'Could not load this diff.')),
558
+ });
559
+ }
560
+ toggleOpen() {
561
+ this.open.set(!this.open());
562
+ }
563
+ moreContext() {
564
+ const next = CONTEXT.find((c) => c > this.context());
565
+ if (next)
566
+ this.context.set(next);
567
+ }
453
568
  key(l) {
454
569
  return `${l.kind === 'delete' ? 'old' : 'new'}:${this.lineOf(l)}`;
455
570
  }
456
571
  lineOf(l) {
457
572
  return (l.kind === 'delete' ? l.old : l.new) ?? 0;
458
573
  }
574
+ /** The lines of a split row that threads and drafts hang from (a context line once). */
575
+ rowLines(r) {
576
+ if (r.left && r.right && r.left === r.right)
577
+ return [r.left];
578
+ return [r.left, r.right].filter((l) => !!l);
579
+ }
459
580
  threadsAt(l) {
460
581
  const side = l.kind === 'delete' ? 'old' : 'new';
461
582
  const n = this.lineOf(l);
@@ -467,11 +588,11 @@ class DiffView {
467
588
  this.drafting.set(this.key(l));
468
589
  this.draftBody.set('');
469
590
  }
470
- send(l) {
591
+ send(l, pending) {
471
592
  const body = this.draftBody().trim();
472
593
  if (!body)
473
594
  return;
474
- this.comment.emit({ path: this.file().path, side: l.kind === 'delete' ? 'old' : 'new', line: this.lineOf(l), body });
595
+ this.comment.emit({ path: this.file().path, side: l.kind === 'delete' ? 'old' : 'new', line: this.lineOf(l), body, pending });
475
596
  this.drafting.set('');
476
597
  this.draftBody.set('');
477
598
  }
@@ -486,130 +607,254 @@ class DiffView {
486
607
  this.setReply(id, '');
487
608
  }
488
609
  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">
610
+ 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: `
611
+ <div class="o-card" [attr.id]="'file-' + file().path" [attr.data-testid]="'diff-' + file().path">
491
612
  <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>
613
+ <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
614
  <span class="o-mono o-sm o-ell">{{ file().oldPath && file().oldPath !== file().path ? file().oldPath + ' → ' : '' }}{{ file().path }}</span>
494
615
  <span class="o-grn-t o-sm">+{{ file().additions }}</span><span class="o-red-t o-sm">−{{ file().deletions }}</span>
495
616
  @if (threadsHere().length) {<span class="o-faint o-sm">💬 {{ threadsHere().length }}</span>}
496
- <span class="o-r"><ng-content /></span>
617
+ <span class="o-r o-flex" style="gap:6px">
618
+ @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>}
619
+ <ng-content />
620
+ @if (viewable()) {
621
+ <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>
622
+ }
623
+ </span>
497
624
  </div>
498
625
  @if (open()) {
499
626
  @if (file().binary) {
500
627
  <div class="o-empty o-sm">A binary file.</div>
628
+ } @else if (waiting()) {
629
+ <div class="o-empty o-sm o-col" style="gap:8px;align-items:center">
630
+ <span>{{ file().additions + file().deletions }} changed lines.</span>
631
+ <button type="button" class="o-btn o-sm" (click)="waiting.set(false); load()">Load diff</button>
632
+ </div>
501
633
  } @else if (error()) {
502
634
  <div class="o-note o-red" role="alert">{{ error() }}</div>
503
635
  } @else if (diff(); as d) {
504
636
  @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
- }
637
+ @if (mode() === 'split') {
638
+ <div class="o-code split" style="overflow-x:auto" role="table" [attr.aria-label]="'Changes to ' + file().path + ', side by side'">
639
+ @for (h of d.hunks; track $index; let hi = $index) {
640
+ <div class="hunk o-mono full" role="row">{{ h.header }}</div>
641
+ @for (r of splitRows()[hi]; track $index) {
642
+ @if (r.left; as l) {
643
+ <div class="o-ln side-l" role="row" [class.o-del]="l.kind === 'delete'" [attr.data-testid]="'split-old-' + l.old">
644
+ <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.old }}</span>
645
+ <span class="o-sr-only">{{ l.kind === 'delete' ? 'removed' : '' }}</span>
646
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
522
647
  </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>
648
+ } @else {<div class="o-ln side-l empty-side" aria-hidden="true"><span class="o-n"></span><span class="o-t"></span></div>}
649
+ @if (r.right; as l) {
650
+ <div class="o-ln" role="row" [class.o-add]="l.kind === 'add'" [attr.data-testid]="'split-new-' + l.new">
651
+ <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.new }}</span>
652
+ <span class="o-sr-only">{{ l.kind === 'add' ? 'added' : '' }}</span>
653
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
654
+ </div>
655
+ } @else {<div class="o-ln empty-side" aria-hidden="true"><span class="o-n"></span><span class="o-t"></span></div>}
656
+ @for (l of rowLines(r); track $index) {
657
+ @for (t of threadsAt(l); track t.id) {<div class="full"><ng-container *ngTemplateOutlet="threadTpl; context: { $implicit: t }" /></div>}
658
+ @if (drafting() === key(l)) {<div class="full"><ng-container *ngTemplateOutlet="draftTpl; context: { $implicit: l }" /></div>}
659
+ }
531
660
  }
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>
661
+ } @empty {<div class="o-empty o-sm full">{{ ignoreSpace() ? 'Only whitespace changed.' : 'No changes to show.' }}</div>}
662
+ </div>
663
+ } @else {
664
+ <div class="o-code" style="overflow-x:auto" role="table" [attr.aria-label]="'Changes to ' + file().path">
665
+ @for (h of d.hunks; track $index) {
666
+ <div class="hunk o-mono" role="row">{{ h.header }}</div>
667
+ @for (l of h.lines; track $index) {
668
+ <div class="o-ln" role="row" [class.o-add]="l.kind === 'add'" [class.o-del]="l.kind === 'delete'">
669
+ <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>
670
+ <span class="o-s" aria-hidden="true">{{ l.kind === 'add' ? '+' : l.kind === 'delete' ? '−' : ' ' }}</span>
671
+ <span class="o-sr-only">{{ l.kind === 'add' ? 'added' : l.kind === 'delete' ? 'removed' : '' }}</span>
672
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
673
+ </div>
674
+ @for (t of threadsAt(l); track t.id) {<ng-container *ngTemplateOutlet="threadTpl; context: { $implicit: t }" />}
675
+ @if (drafting() === key(l)) {<ng-container *ngTemplateOutlet="draftTpl; context: { $implicit: l }" />}
537
676
  }
677
+ } @empty {
678
+ <div class="o-empty o-sm">{{ ignoreSpace() ? 'Only whitespace changed.' : 'No changes to show.' }}</div>
538
679
  }
539
- } @empty {
540
- <div class="o-empty o-sm">No changes to show.</div>
541
- }
542
- </div>
680
+ </div>
681
+ }
543
682
  } @else {
544
683
  <div class="o-empty o-sm">Loading…</div>
545
684
  }
546
685
  }
547
686
  </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 }); }
687
+
688
+ <ng-template #threadTpl let-t>
689
+ <div class="o-thread" [attr.data-testid]="'thread-' + t.id">
690
+ <div class="o-cb o-col" style="gap:6px;padding:10px 12px">
691
+ @for (c of t.comments; track c.id) {
692
+ <span class="o-sm"><b>{{ people().name(c.authorId) }}</b><span class="o-faint"> · {{ ago(c.createdAt) }}</span>
693
+ @if (c.pending) {<span class="o-chip o-amb" style="margin-left:6px">Pending</span>
694
+ @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>}
695
+ }
696
+ </span>
697
+ <span style="white-space:pre-wrap">{{ c.body }}</span>
698
+ }
699
+ </div>
700
+ @if (canComment()) {
701
+ <div class="o-cf">
702
+ <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)" />
703
+ <button type="button" class="o-btn o-sm" (click)="sendReply(t.id)">Reply</button>
704
+ <button type="button" class="o-btn o-sm" (click)="resolve.emit({ threadId: t.id, resolved: !t.resolved })">{{ t.resolved ? 'Unresolve' : 'Resolve' }}</button>
705
+ </div>
706
+ }
707
+ </div>
708
+ </ng-template>
709
+
710
+ <ng-template #draftTpl let-l>
711
+ <div class="o-thread"><div class="o-cb o-col" style="gap:8px;padding:10px 12px">
712
+ <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>
713
+ <div class="o-flex o-wrap"><span class="o-faint o-xs">Markdown works.</span>
714
+ <button type="button" class="o-btn o-sm o-r" (click)="drafting.set('')">Cancel</button>
715
+ <button type="button" class="o-btn o-sm" [disabled]="!draftBody().trim()" (click)="send(l, false)" data-testid="line-comment-add">Add single comment</button>
716
+ <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>
717
+ </div>
718
+ </div></div>
719
+ </ng-template>
720
+ `, 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
721
  }
550
722
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: DiffView, decorators: [{
551
723
  type: Component,
552
- args: [{ selector: 'o-diff', imports: [FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
553
- <div class="o-card" [attr.data-testid]="'diff-' + file().path">
724
+ args: [{ selector: 'o-diff', imports: [FormsModule, NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, template: `
725
+ <div class="o-card" [attr.id]="'file-' + file().path" [attr.data-testid]="'diff-' + file().path">
554
726
  <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>
727
+ <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
728
  <span class="o-mono o-sm o-ell">{{ file().oldPath && file().oldPath !== file().path ? file().oldPath + ' → ' : '' }}{{ file().path }}</span>
557
729
  <span class="o-grn-t o-sm">+{{ file().additions }}</span><span class="o-red-t o-sm">−{{ file().deletions }}</span>
558
730
  @if (threadsHere().length) {<span class="o-faint o-sm">💬 {{ threadsHere().length }}</span>}
559
- <span class="o-r"><ng-content /></span>
731
+ <span class="o-r o-flex" style="gap:6px">
732
+ @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>}
733
+ <ng-content />
734
+ @if (viewable()) {
735
+ <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>
736
+ }
737
+ </span>
560
738
  </div>
561
739
  @if (open()) {
562
740
  @if (file().binary) {
563
741
  <div class="o-empty o-sm">A binary file.</div>
742
+ } @else if (waiting()) {
743
+ <div class="o-empty o-sm o-col" style="gap:8px;align-items:center">
744
+ <span>{{ file().additions + file().deletions }} changed lines.</span>
745
+ <button type="button" class="o-btn o-sm" (click)="waiting.set(false); load()">Load diff</button>
746
+ </div>
564
747
  } @else if (error()) {
565
748
  <div class="o-note o-red" role="alert">{{ error() }}</div>
566
749
  } @else if (diff(); as d) {
567
750
  @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
- }
751
+ @if (mode() === 'split') {
752
+ <div class="o-code split" style="overflow-x:auto" role="table" [attr.aria-label]="'Changes to ' + file().path + ', side by side'">
753
+ @for (h of d.hunks; track $index; let hi = $index) {
754
+ <div class="hunk o-mono full" role="row">{{ h.header }}</div>
755
+ @for (r of splitRows()[hi]; track $index) {
756
+ @if (r.left; as l) {
757
+ <div class="o-ln side-l" role="row" [class.o-del]="l.kind === 'delete'" [attr.data-testid]="'split-old-' + l.old">
758
+ <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.old }}</span>
759
+ <span class="o-sr-only">{{ l.kind === 'delete' ? 'removed' : '' }}</span>
760
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
585
761
  </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>
762
+ } @else {<div class="o-ln side-l empty-side" aria-hidden="true"><span class="o-n"></span><span class="o-t"></span></div>}
763
+ @if (r.right; as l) {
764
+ <div class="o-ln" role="row" [class.o-add]="l.kind === 'add'" [attr.data-testid]="'split-new-' + l.new">
765
+ <span class="o-n" role="rowheader" [class.cmt]="canComment()" (click)="startComment(l)" [attr.title]="canComment() ? 'Comment on this line' : null">{{ l.new }}</span>
766
+ <span class="o-sr-only">{{ l.kind === 'add' ? 'added' : '' }}</span>
767
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
768
+ </div>
769
+ } @else {<div class="o-ln empty-side" aria-hidden="true"><span class="o-n"></span><span class="o-t"></span></div>}
770
+ @for (l of rowLines(r); track $index) {
771
+ @for (t of threadsAt(l); track t.id) {<div class="full"><ng-container *ngTemplateOutlet="threadTpl; context: { $implicit: t }" /></div>}
772
+ @if (drafting() === key(l)) {<div class="full"><ng-container *ngTemplateOutlet="draftTpl; context: { $implicit: l }" /></div>}
773
+ }
594
774
  }
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>
775
+ } @empty {<div class="o-empty o-sm full">{{ ignoreSpace() ? 'Only whitespace changed.' : 'No changes to show.' }}</div>}
776
+ </div>
777
+ } @else {
778
+ <div class="o-code" style="overflow-x:auto" role="table" [attr.aria-label]="'Changes to ' + file().path">
779
+ @for (h of d.hunks; track $index) {
780
+ <div class="hunk o-mono" role="row">{{ h.header }}</div>
781
+ @for (l of h.lines; track $index) {
782
+ <div class="o-ln" role="row" [class.o-add]="l.kind === 'add'" [class.o-del]="l.kind === 'delete'">
783
+ <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>
784
+ <span class="o-s" aria-hidden="true">{{ l.kind === 'add' ? '+' : l.kind === 'delete' ? '−' : ' ' }}</span>
785
+ <span class="o-sr-only">{{ l.kind === 'add' ? 'added' : l.kind === 'delete' ? 'removed' : '' }}</span>
786
+ <span class="o-t" role="cell" [innerHTML]="l.html || l.text"></span>
787
+ </div>
788
+ @for (t of threadsAt(l); track t.id) {<ng-container *ngTemplateOutlet="threadTpl; context: { $implicit: t }" />}
789
+ @if (drafting() === key(l)) {<ng-container *ngTemplateOutlet="draftTpl; context: { $implicit: l }" />}
600
790
  }
791
+ } @empty {
792
+ <div class="o-empty o-sm">{{ ignoreSpace() ? 'Only whitespace changed.' : 'No changes to show.' }}</div>
601
793
  }
602
- } @empty {
603
- <div class="o-empty o-sm">No changes to show.</div>
604
- }
605
- </div>
794
+ </div>
795
+ }
606
796
  } @else {
607
797
  <div class="o-empty o-sm">Loading…</div>
608
798
  }
609
799
  }
610
800
  </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"] }] } });
801
+
802
+ <ng-template #threadTpl let-t>
803
+ <div class="o-thread" [attr.data-testid]="'thread-' + t.id">
804
+ <div class="o-cb o-col" style="gap:6px;padding:10px 12px">
805
+ @for (c of t.comments; track c.id) {
806
+ <span class="o-sm"><b>{{ people().name(c.authorId) }}</b><span class="o-faint"> · {{ ago(c.createdAt) }}</span>
807
+ @if (c.pending) {<span class="o-chip o-amb" style="margin-left:6px">Pending</span>
808
+ @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>}
809
+ }
810
+ </span>
811
+ <span style="white-space:pre-wrap">{{ c.body }}</span>
812
+ }
813
+ </div>
814
+ @if (canComment()) {
815
+ <div class="o-cf">
816
+ <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)" />
817
+ <button type="button" class="o-btn o-sm" (click)="sendReply(t.id)">Reply</button>
818
+ <button type="button" class="o-btn o-sm" (click)="resolve.emit({ threadId: t.id, resolved: !t.resolved })">{{ t.resolved ? 'Unresolve' : 'Resolve' }}</button>
819
+ </div>
820
+ }
821
+ </div>
822
+ </ng-template>
823
+
824
+ <ng-template #draftTpl let-l>
825
+ <div class="o-thread"><div class="o-cb o-col" style="gap:8px;padding:10px 12px">
826
+ <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>
827
+ <div class="o-flex o-wrap"><span class="o-faint o-xs">Markdown works.</span>
828
+ <button type="button" class="o-btn o-sm o-r" (click)="drafting.set('')">Cancel</button>
829
+ <button type="button" class="o-btn o-sm" [disabled]="!draftBody().trim()" (click)="send(l, false)" data-testid="line-comment-add">Add single comment</button>
830
+ <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>
831
+ </div>
832
+ </div></div>
833
+ </ng-template>
834
+ `, 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"] }]
835
+ }], 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"] }] } });
836
+ /** Pairs a hunk's lines side by side: context on both sides, a run of removals beside the run of additions that follows it. */
837
+ function pair(lines) {
838
+ const rows = [];
839
+ let i = 0;
840
+ while (i < lines.length) {
841
+ const l = lines[i];
842
+ if (l.kind === 'context') {
843
+ rows.push({ left: l, right: l });
844
+ i++;
845
+ continue;
846
+ }
847
+ const dels = [];
848
+ const adds = [];
849
+ while (i < lines.length && lines[i].kind === 'delete')
850
+ dels.push(lines[i++]);
851
+ while (i < lines.length && lines[i].kind === 'add')
852
+ adds.push(lines[i++]);
853
+ for (let k = 0; k < Math.max(dels.length, adds.length); k++)
854
+ rows.push({ left: dels[k], right: adds[k] });
855
+ }
856
+ return rows;
857
+ }
613
858
 
614
859
  // /{org}/{project}/code (prototype "code_repos"): the project's repositories.
615
860
  class ReposPage {
@@ -1143,7 +1388,7 @@ class RepoHomePage extends RepoPage {
1143
1388
  </div>
1144
1389
  <div class="o-col" style="gap:14px">
1145
1390
  <div class="o-card o-cb o-col"><b>About</b><span class="o-mut o-lh">{{ r.description || 'No description.' }}</span><span class="o-faint o-sm">Default branch <span class="o-mono">{{ r.defaultBranch }}</span></span></div>
1146
- <div class="o-card o-cb o-col"><div class="o-flex"><b>Releases</b><b class="o-ph-tag o-ga o-r">GA</b></div><span class="o-faint o-sm">Tags and release notes arrive at general availability.</span></div>
1391
+ <div class="o-card o-cb o-col"><b>Releases</b><a class="o-sm" [routerLink]="link('releases')" data-testid="repo-releases">Releases and tags</a></div>
1147
1392
  <div class="o-card o-cb o-col"><b>Deployments</b><span class="o-faint o-sm">Environments show here once Deploy is set up.</span></div>
1148
1393
  @if (langs().length) {
1149
1394
  <div class="o-card o-cb o-col">
@@ -1263,7 +1508,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1263
1508
  </div>
1264
1509
  <div class="o-col" style="gap:14px">
1265
1510
  <div class="o-card o-cb o-col"><b>About</b><span class="o-mut o-lh">{{ r.description || 'No description.' }}</span><span class="o-faint o-sm">Default branch <span class="o-mono">{{ r.defaultBranch }}</span></span></div>
1266
- <div class="o-card o-cb o-col"><div class="o-flex"><b>Releases</b><b class="o-ph-tag o-ga o-r">GA</b></div><span class="o-faint o-sm">Tags and release notes arrive at general availability.</span></div>
1511
+ <div class="o-card o-cb o-col"><b>Releases</b><a class="o-sm" [routerLink]="link('releases')" data-testid="repo-releases">Releases and tags</a></div>
1267
1512
  <div class="o-card o-cb o-col"><b>Deployments</b><span class="o-faint o-sm">Environments show here once Deploy is set up.</span></div>
1268
1513
  @if (langs().length) {
1269
1514
  <div class="o-card o-cb o-col">
@@ -1686,7 +1931,7 @@ class CommitPage extends RepoPage {
1686
1931
  <div class="o-card"><div class="o-empty">Loading…</div></div>
1687
1932
  }
1688
1933
  </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 }); }
1934
+ `, 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
1935
  }
1691
1936
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: CommitPage, decorators: [{
1692
1937
  type: Component,
@@ -2176,6 +2421,17 @@ class PullPage extends RepoPage {
2176
2421
  this.initials = initials;
2177
2422
  this.dot = checkDot;
2178
2423
  this.threads = computed(() => this.timeline().threads, ...(ngDevMode ? [{ debugName: "threads" }] : /* istanbul ignore next */ []));
2424
+ this.mode = signal(readPref('origine.diff.mode') === 'split' ? 'split' : 'unified', ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
2425
+ this.hideWs = signal(false, ...(ngDevMode ? [{ debugName: "hideWs" }] : /* istanbul ignore next */ []));
2426
+ /** Files marked viewed, kept per pull request head in this browser (a new push resets them). */
2427
+ this.viewed = signal({}, ...(ngDevMode ? [{ debugName: "viewed" }] : /* istanbul ignore next */ []));
2428
+ this.viewedCount = computed(() => (this.cmp()?.files ?? []).filter((f) => this.viewed()[f.path]).length, ...(ngDevMode ? [{ debugName: "viewedCount" }] : /* istanbul ignore next */ []));
2429
+ /** The caller's pending review comments (shown only to them until the review is submitted). */
2430
+ this.myPending = computed(() => {
2431
+ const me = this.host.meId();
2432
+ const t = this.timeline();
2433
+ 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);
2434
+ }, ...(ngDevMode ? [{ debugName: "myPending" }] : /* istanbul ignore next */ []));
2179
2435
  this.openThreads = computed(() => this.threads().filter((t) => !t.resolved && !t.outdated).length, ...(ngDevMode ? [{ debugName: "openThreads" }] : /* istanbul ignore next */ []));
2180
2436
  this.tabs = computed(() => {
2181
2437
  const p = this.pr();
@@ -2194,8 +2450,12 @@ class PullPage extends RepoPage {
2194
2450
  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
2451
  this.reviewKinds = computed(() => {
2196
2452
  const need = this.pr()?.gate.rows.find((r) => /required by branch protection|requires/.test(r.text + (r.meta ?? '')));
2453
+ const comment = { id: 'commented', label: 'Comment', hint: 'General feedback without approving.' };
2454
+ // the author (or a role that cannot push) still posts their pending comments as a review
2455
+ if (!this.pr()?.canReview)
2456
+ return [{ ...comment, hint: 'Post your pending comments.' }];
2197
2457
  return [
2198
- { id: 'commented', label: 'Comment', hint: 'General feedback without approving.' },
2458
+ comment,
2199
2459
  { id: 'approved', label: 'Approve', hint: need ? `Counts toward the approvals ${this.pr()?.target} requires.` : 'Approve these changes.' },
2200
2460
  { id: 'changes_requested', label: 'Request changes', hint: 'Blocks merge until you approve.' },
2201
2461
  ];
@@ -2271,7 +2531,32 @@ class PullPage extends RepoPage {
2271
2531
  this.run(this.api.comment(this.projectId(), this.name(), this.number, { body }), 'Could not add the comment.', () => this.draft.set(''));
2272
2532
  }
2273
2533
  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.');
2534
+ 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)
2535
+ this.note.set('Added to your review. Submit it from Review changes to post your comments.'); });
2536
+ }
2537
+ discard(id) {
2538
+ this.run(this.api.discardComment(this.projectId(), this.name(), this.number, id), 'Could not discard the comment.');
2539
+ }
2540
+ setMode(m) {
2541
+ this.mode.set(m);
2542
+ writePref('origine.diff.mode', m);
2543
+ }
2544
+ viewedKey() {
2545
+ return `origine.viewed.${this.projectId()}.${this.name()}.${this.number}.${this.pr()?.headSha ?? ''}`;
2546
+ }
2547
+ isViewed(path) {
2548
+ return !!this.viewed()[path];
2549
+ }
2550
+ setViewed(path, on) {
2551
+ const v = { ...this.viewed(), [path]: on };
2552
+ if (!on)
2553
+ delete v[path];
2554
+ this.viewed.set(v);
2555
+ writePref(this.viewedKey(), JSON.stringify(v));
2556
+ }
2557
+ jump(path) {
2558
+ const el = this.el.nativeElement.querySelector(`[id="file-${CSS.escape(path)}"]`);
2559
+ el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
2275
2560
  }
2276
2561
  reply(r) {
2277
2562
  this.run(this.api.comment(this.projectId(), this.name(), this.number, { body: r.body, threadId: r.threadId }), 'Could not reply.');
@@ -2310,7 +2595,16 @@ class PullPage extends RepoPage {
2310
2595
  const project = this.projectId();
2311
2596
  this.api.pull(project, this.name(), this.number).subscribe({
2312
2597
  next: (p) => {
2598
+ const headChanged = this.pr()?.headSha !== p.headSha;
2313
2599
  this.pr.set(p);
2600
+ if (headChanged) {
2601
+ try {
2602
+ this.viewed.set(JSON.parse(readPref(this.viewedKey()) || '{}'));
2603
+ }
2604
+ catch {
2605
+ this.viewed.set({});
2606
+ }
2607
+ }
2314
2608
  if (first) {
2315
2609
  this.method.set(p.mergeMethods[0] ?? 'merge');
2316
2610
  this.delBranch.set(p.deleteBranchOnMerge);
@@ -2341,17 +2635,18 @@ class PullPage extends RepoPage {
2341
2635
  <div class="o-tabs" role="tablist">
2342
2636
  @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
2637
  <span class="o-r"></span>
2344
- @if (p.state === 'open' && p.canReview) {
2638
+ @if (p.state === 'open' && (p.canReview || myPending().length)) {
2345
2639
  <div class="anchor">
2346
2640
  <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
2641
  @if (reviewOpen()) {
2348
2642
  <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
2643
  <b>Finish your review</b>
2644
+ <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
2645
  <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
2646
  @for (k of reviewKinds(); track k.id) {
2352
2647
  <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
2648
  }
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>
2649
+ <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
2650
  </div>
2356
2651
  }
2357
2652
  </div>
@@ -2435,10 +2730,28 @@ class PullPage extends RepoPage {
2435
2730
  </div>
2436
2731
  }
2437
2732
  @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>}
2733
+ <div class="o-flex o-wrap">
2734
+ <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>
2735
+ <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>
2736
+ @if (myPending().length) {<span class="o-chip o-amb" data-testid="pending-count">{{ myPending().length }} pending {{ myPending().length === 1 ? 'comment' : 'comments' }}</span>}
2737
+ <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>
2738
+ </div>
2739
+ <div class="files-split">
2740
+ @if ((cmp()?.files?.length ?? 0) > 1) {
2741
+ <div class="o-card files-tree" role="navigation" aria-label="Files">
2742
+ @for (f of cmp()?.files ?? []; track f.path) {
2743
+ <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>
2744
+ }
2745
+ </div>
2746
+ }
2747
+ <div class="o-col" style="gap:14px;min-width:0">
2748
+ @for (f of cmp()?.files ?? []; track f.path) {
2749
+ <o-diff [repo]="name()" [file]="f" [head]="p.headSha" [base]="p.mergeBase" [threads]="threads()" [canComment]="p.state === 'open'" [mode]="mode()" [ignoreSpace]="hideWs()"
2750
+ [viewed]="isViewed(f.path)" [reviewing]="myPending().length > 0" (viewedChange)="setViewed(f.path, $event)"
2751
+ (comment)="lineComment($event)" (reply)="reply($event)" (resolve)="resolve($event)" (discard)="discard($event)" />
2752
+ } @empty {<div class="o-card"><div class="o-empty">{{ cmp() ? 'No changes.' : 'Loading…' }}</div></div>}
2753
+ </div>
2754
+ </div>
2442
2755
  }
2443
2756
  @case ('checks') {
2444
2757
  <div class="o-card">
@@ -2471,7 +2784,7 @@ class PullPage extends RepoPage {
2471
2784
  <div class="o-card"><div class="o-empty">Loading…</div></div>
2472
2785
  }
2473
2786
  </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 }); }
2787
+ `, 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
2788
  }
2476
2789
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: PullPage, decorators: [{
2477
2790
  type: Component,
@@ -2492,17 +2805,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2492
2805
  <div class="o-tabs" role="tablist">
2493
2806
  @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
2807
  <span class="o-r"></span>
2495
- @if (p.state === 'open' && p.canReview) {
2808
+ @if (p.state === 'open' && (p.canReview || myPending().length)) {
2496
2809
  <div class="anchor">
2497
2810
  <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
2811
  @if (reviewOpen()) {
2499
2812
  <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
2813
  <b>Finish your review</b>
2814
+ <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
2815
  <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
2816
  @for (k of reviewKinds(); track k.id) {
2503
2817
  <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
2818
  }
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>
2819
+ <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
2820
  </div>
2507
2821
  }
2508
2822
  </div>
@@ -2586,10 +2900,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2586
2900
  </div>
2587
2901
  }
2588
2902
  @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>}
2903
+ <div class="o-flex o-wrap">
2904
+ <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>
2905
+ <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>
2906
+ @if (myPending().length) {<span class="o-chip o-amb" data-testid="pending-count">{{ myPending().length }} pending {{ myPending().length === 1 ? 'comment' : 'comments' }}</span>}
2907
+ <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>
2908
+ </div>
2909
+ <div class="files-split">
2910
+ @if ((cmp()?.files?.length ?? 0) > 1) {
2911
+ <div class="o-card files-tree" role="navigation" aria-label="Files">
2912
+ @for (f of cmp()?.files ?? []; track f.path) {
2913
+ <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>
2914
+ }
2915
+ </div>
2916
+ }
2917
+ <div class="o-col" style="gap:14px;min-width:0">
2918
+ @for (f of cmp()?.files ?? []; track f.path) {
2919
+ <o-diff [repo]="name()" [file]="f" [head]="p.headSha" [base]="p.mergeBase" [threads]="threads()" [canComment]="p.state === 'open'" [mode]="mode()" [ignoreSpace]="hideWs()"
2920
+ [viewed]="isViewed(f.path)" [reviewing]="myPending().length > 0" (viewedChange)="setViewed(f.path, $event)"
2921
+ (comment)="lineComment($event)" (reply)="reply($event)" (resolve)="resolve($event)" (discard)="discard($event)" />
2922
+ } @empty {<div class="o-card"><div class="o-empty">{{ cmp() ? 'No changes.' : 'Loading…' }}</div></div>}
2923
+ </div>
2924
+ </div>
2593
2925
  }
2594
2926
  @case ('checks') {
2595
2927
  <div class="o-card">
@@ -2622,11 +2954,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2622
2954
  <div class="o-card"><div class="o-empty">Loading…</div></div>
2623
2955
  }
2624
2956
  </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"] }]
2957
+ `, 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
2958
  }], ctorParameters: () => [], propDecorators: { outside: [{
2627
2959
  type: HostListener,
2628
2960
  args: ['document:click', ['$event']]
2629
2961
  }] } });
2962
+ // Per-viewer conveniences (the diff view, files viewed); storage can be missing or blocked.
2963
+ function readPref(key) {
2964
+ try {
2965
+ return localStorage.getItem(key) ?? '';
2966
+ }
2967
+ catch {
2968
+ return '';
2969
+ }
2970
+ }
2971
+ function writePref(key, value) {
2972
+ try {
2973
+ localStorage.setItem(key, value);
2974
+ }
2975
+ catch {
2976
+ /* private window or blocked storage: the setting lasts for this page only */
2977
+ }
2978
+ }
2630
2979
 
2631
2980
  const BLANK = { id: '', pattern: '', blockDirectPush: true, blockForcePush: true, blockDeletion: true, requiredApprovals: 1, dismissStaleApprovals: true, requireUpToDate: false, requiredChecks: [] };
2632
2981
  // /{org}/{project}/code/{repo}/settings?section= (prototype "code_settings"): general settings and
@@ -2955,6 +3304,386 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2955
3304
  }]
2956
3305
  }], ctorParameters: () => [] });
2957
3306
 
3307
+ // /{org}/{project}/code/{repo}/releases (prototype code_tags): releases newest first with their
3308
+ // notes, files and source archives, the latest marked; drafts for those who can push; a release
3309
+ // drafted on a new or existing tag with notes generated from the done issues since the previous
3310
+ // one; and the repository's tags.
3311
+ class ReleasesPage extends RepoPage {
3312
+ constructor() {
3313
+ super(...arguments);
3314
+ this.releases = signal([], ...(ngDevMode ? [{ debugName: "releases" }] : /* istanbul ignore next */ []));
3315
+ this.tags = signal([], ...(ngDevMode ? [{ debugName: "tags" }] : /* istanbul ignore next */ []));
3316
+ this.loaded = signal(false, ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
3317
+ this.tagging = signal(false, ...(ngDevMode ? [{ debugName: "tagging" }] : /* istanbul ignore next */ []));
3318
+ this.draft = signal(null, ...(ngDevMode ? [{ debugName: "draft" }] : /* istanbul ignore next */ []));
3319
+ this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
3320
+ this.confirming = signal('', ...(ngDevMode ? [{ debugName: "confirming" }] : /* istanbul ignore next */ []));
3321
+ this.freeTags = computed(() => this.tags().filter((t) => !this.used(t.name)), ...(ngDevMode ? [{ debugName: "freeTags" }] : /* istanbul ignore next */ []));
3322
+ this.ago = (s) => ago(s);
3323
+ this.tagName = '';
3324
+ this.tagFrom = '';
3325
+ this.alsoTag = false;
3326
+ }
3327
+ onRepo(r) {
3328
+ this.tagFrom = r.defaultBranch;
3329
+ this.load();
3330
+ }
3331
+ md(s) {
3332
+ return marked.parse(s || '', { async: false, gfm: true, breaks: true });
3333
+ }
3334
+ used(tag) {
3335
+ return this.releases().some((r) => r.tag === tag);
3336
+ }
3337
+ load() {
3338
+ const r = this.repo();
3339
+ if (!r)
3340
+ return;
3341
+ forkJoin({ rel: this.api.releases(this.projectId(), r.name), tags: this.api.refs(this.projectId(), r.name, 'tag') }).subscribe({
3342
+ next: ({ rel, tags }) => { this.releases.set(rel); this.tags.set(sortTags(tags)); this.loaded.set(true); },
3343
+ error: (e) => { this.loaded.set(true); this.error.set(apiMessage(e, 'Could not load the releases.')); },
3344
+ });
3345
+ }
3346
+ run(o, fallback, done) {
3347
+ this.busy.set(true);
3348
+ this.error.set('');
3349
+ this.note.set('');
3350
+ o.subscribe({
3351
+ next: () => { this.busy.set(false); done?.(); this.load(); },
3352
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, fallback)); },
3353
+ });
3354
+ }
3355
+ createTag() {
3356
+ const r = this.repo();
3357
+ const t = this.tagName.trim();
3358
+ if (!r || !t)
3359
+ return;
3360
+ this.run(this.api.createTag(this.projectId(), r.name, t, this.tagFrom.trim() || r.defaultBranch), 'Could not create the tag.', () => {
3361
+ this.tagging.set(false);
3362
+ this.note.set(`Tag ${t} created.`);
3363
+ this.tagName = '';
3364
+ });
3365
+ }
3366
+ removeTag(t) {
3367
+ const r = this.repo();
3368
+ if (!r)
3369
+ return;
3370
+ this.run(this.api.deleteTag(this.projectId(), r.name, t.name), `Could not delete ${t.name}.`, () => this.note.set(`Tag ${t.name} deleted.`));
3371
+ }
3372
+ startRelease() {
3373
+ const r = this.repo();
3374
+ this.tagging.set(false);
3375
+ this.draft.set({ tag: '', newTag: true, target: r?.defaultBranch ?? 'main', title: '', notes: '', prerelease: false, draft: true });
3376
+ }
3377
+ setTag(d, tag) {
3378
+ d.tag = tag.trim();
3379
+ d.newTag = !this.tags().some((t) => t.name === d.tag);
3380
+ }
3381
+ generate(d) {
3382
+ const r = this.repo();
3383
+ if (!r)
3384
+ return;
3385
+ this.busy.set(true);
3386
+ const target = d.newTag ? d.target || r.defaultBranch : 'refs/tags/' + d.tag;
3387
+ this.api.releaseNotes(this.projectId(), r.name, target).subscribe({
3388
+ next: (n) => { this.busy.set(false); d.notes = n; this.draft.set({ ...d }); },
3389
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not generate the notes.')); },
3390
+ });
3391
+ }
3392
+ edit(r) {
3393
+ this.tagging.set(false);
3394
+ this.draft.set({ id: r.id, version: r.version, tag: r.tag, newTag: false, target: '', title: r.title === r.tag ? '' : r.title, notes: r.notes, prerelease: r.prerelease, draft: r.draft });
3395
+ }
3396
+ save(d, asDraft) {
3397
+ const r = this.repo();
3398
+ if (!r)
3399
+ return;
3400
+ if (d.id) {
3401
+ this.run(this.api.updateRelease(this.projectId(), r.name, d.id, { title: d.title, notes: d.notes, prerelease: d.prerelease, draft: d.draft && asDraft, version: d.version }), 'Could not save the release.', () => { this.draft.set(null); this.note.set(asDraft ? 'Draft saved.' : `${d.tag} saved.`); });
3402
+ return;
3403
+ }
3404
+ this.run(this.api.createRelease(this.projectId(), r.name, { tag: d.tag, target: d.newTag ? d.target || r.defaultBranch : undefined, title: d.title, notes: d.notes, prerelease: d.prerelease, draft: asDraft }), 'Could not create the release.', () => { this.draft.set(null); this.note.set(asDraft ? `Draft ${d.tag} saved.` : `${d.tag} published.`); });
3405
+ }
3406
+ remove(rel) {
3407
+ const r = this.repo();
3408
+ if (!r)
3409
+ return;
3410
+ this.confirming.set('');
3411
+ this.run(this.api.deleteRelease(this.projectId(), r.name, rel.id, this.alsoTag), `Could not delete ${rel.tag}.`, () => this.note.set(`Release ${rel.tag} deleted${this.alsoTag ? ' with its tag' : ''}.`));
3412
+ }
3413
+ upload(rel, ev) {
3414
+ const r = this.repo();
3415
+ const input = ev.target;
3416
+ const file = input.files?.[0];
3417
+ if (!r || !file)
3418
+ return;
3419
+ if (file.size > 25 * 1024 * 1024) {
3420
+ this.error.set('A release file is at most 25 MB.');
3421
+ return;
3422
+ }
3423
+ this.run(this.api.addAsset(this.projectId(), r.name, rel.id, file), 'Could not add the file.', () => { input.value = ''; this.note.set(`${file.name} added to ${rel.tag}.`); });
3424
+ }
3425
+ removeAsset(rel, id) {
3426
+ const r = this.repo();
3427
+ if (!r)
3428
+ return;
3429
+ this.run(this.api.deleteAsset(this.projectId(), r.name, rel.id, id), 'Could not remove the file.');
3430
+ }
3431
+ download(rel, id, filename) {
3432
+ const r = this.repo();
3433
+ if (!r)
3434
+ return;
3435
+ this.api.assetBlob(this.projectId(), r.name, rel.id, id).subscribe({
3436
+ next: (b) => this.save_(b, filename),
3437
+ error: (e) => this.error.set(apiMessage(e, 'Could not download the file.')),
3438
+ });
3439
+ }
3440
+ archive(rel, format) {
3441
+ const r = this.repo();
3442
+ if (!r)
3443
+ return;
3444
+ this.api.archiveUrl(this.projectId(), r.name, 'refs/tags/' + rel.tag, format).subscribe({
3445
+ next: (url) => { if (url)
3446
+ window.location.assign(url); },
3447
+ error: (e) => this.error.set(apiMessage(e, 'Could not prepare the archive.')),
3448
+ });
3449
+ }
3450
+ save_(b, filename) {
3451
+ const url = URL.createObjectURL(b);
3452
+ const link = document.createElement('a');
3453
+ link.href = url;
3454
+ link.download = filename;
3455
+ link.click();
3456
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
3457
+ }
3458
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ReleasesPage, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
3459
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ReleasesPage, isStandalone: true, selector: "o-code-releases", usesInheritance: true, ngImport: i0, template: `
3460
+ <div class="o-page o-narrow">
3461
+ <div class="o-ph">
3462
+ <div class="o-col"><span class="o-eyebrow">{{ name() }}</span><h1 class="o-t">Releases and tags</h1></div>
3463
+ @if (canWrite()) {
3464
+ <div class="o-acts">
3465
+ <button type="button" class="o-btn o-sm" (click)="tagging.set(!tagging()); draft.set(null)" data-testid="tag-new">New tag</button>
3466
+ <button type="button" class="o-btn o-sm o-dark" (click)="startRelease()" data-testid="release-new">Draft release</button>
3467
+ </div>
3468
+ }
3469
+ </div>
3470
+ @if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
3471
+ @if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
3472
+
3473
+ @if (tagging()) {
3474
+ <form class="o-card" (ngSubmit)="createTag()" data-testid="tag-form">
3475
+ <div class="o-ch"><b>New tag</b></div>
3476
+ <div class="o-cb o-flex o-wrap" style="align-items:flex-end">
3477
+ <div class="o-fld"><label for="tg-name">Tag</label><input id="tg-name" class="o-in o-mono" name="tag" placeholder="v1.8.0" [(ngModel)]="tagName" /></div>
3478
+ <div class="o-fld"><label for="tg-from">At</label><input id="tg-from" class="o-in o-mono" name="from" [(ngModel)]="tagFrom" placeholder="main or a commit" /></div>
3479
+ <button type="button" class="o-btn o-sm o-r" (click)="tagging.set(false)">Cancel</button>
3480
+ <button type="submit" class="o-btn o-sm o-dark" [disabled]="busy() || !tagName.trim()">Create tag</button>
3481
+ </div>
3482
+ </form>
3483
+ }
3484
+
3485
+ @if (draft(); as d) {
3486
+ <div class="o-card" data-testid="release-form">
3487
+ <div class="o-ch"><b>{{ d.id ? 'Edit ' + d.tag : 'Draft a release' }}</b></div>
3488
+ <div class="o-cb o-col" style="gap:12px">
3489
+ @if (!d.id) {
3490
+ <div class="o-flex o-wrap" style="align-items:flex-end">
3491
+ <div class="o-fld"><label for="rl-tag">Tag</label>
3492
+ <input id="rl-tag" class="o-in o-mono" list="rl-tags" placeholder="v1.8.0" [ngModel]="d.tag" (ngModelChange)="setTag(d, $event)" data-testid="release-tag" />
3493
+ <datalist id="rl-tags">@for (t of freeTags(); track t.name) {<option [value]="t.name"></option>}</datalist>
3494
+ </div>
3495
+ @if (d.newTag) {<div class="o-fld"><label for="rl-target">New tag at</label><input id="rl-target" class="o-in o-mono" [(ngModel)]="d.target" placeholder="main" /></div>}
3496
+ <span class="o-hint" style="padding-bottom:8px">{{ d.tag ? (d.newTag ? 'A new tag, made when you save.' : 'An existing tag.') : '' }}</span>
3497
+ </div>
3498
+ }
3499
+ <div class="o-fld"><label for="rl-title">Title</label><input id="rl-title" class="o-in" maxlength="255" [(ngModel)]="d.title" placeholder="Defaults to the tag" data-testid="release-title" /></div>
3500
+ <div class="o-fld">
3501
+ <div class="o-flex"><label for="rl-notes">Notes</label>
3502
+ @if (!d.id) {<button type="button" class="o-lnk o-r o-sm" [disabled]="busy() || !d.tag" (click)="generate(d)" data-testid="release-generate">Generate from done issues</button>}
3503
+ </div>
3504
+ <textarea id="rl-notes" class="o-in" rows="7" style="height:auto;padding:8px 10px" [(ngModel)]="d.notes" placeholder="Markdown works." data-testid="release-notes"></textarea>
3505
+ <span class="o-hint">Generated notes list the done issues and merged pull requests since the previous release.</span>
3506
+ </div>
3507
+ <label class="o-flex o-sm"><input type="checkbox" [(ngModel)]="d.prerelease" /> Pre-release (never marked Latest)</label>
3508
+ </div>
3509
+ <div class="o-cf">
3510
+ <button type="button" class="o-btn o-sm o-r" (click)="draft.set(null)">Cancel</button>
3511
+ @if (!d.id || d.draft) {<button type="button" class="o-btn o-sm" [disabled]="busy() || !d.tag" (click)="save(d, true)" data-testid="release-save-draft">Save draft</button>}
3512
+ <button type="button" class="o-btn o-sm o-dark" [disabled]="busy() || !d.tag" (click)="save(d, false)" data-testid="release-publish">{{ d.id && !d.draft ? 'Save' : 'Publish release' }}</button>
3513
+ </div>
3514
+ </div>
3515
+ }
3516
+
3517
+ @for (r of releases(); track r.id) {
3518
+ <div class="o-card rel" [attr.data-testid]="'release-' + r.tag">
3519
+ <div class="o-ch">
3520
+ <b class="o-md">{{ r.tag }}</b>
3521
+ @if (r.latest) {<span class="o-chip o-grn">Latest</span>}
3522
+ @if (r.draft) {<span class="o-chip o-amb">Draft</span>}
3523
+ @if (r.prerelease) {<span class="o-chip">Pre-release</span>}
3524
+ <span class="o-r o-mono o-sm o-faint">{{ r.short }} · {{ ago(r.publishedAt || r.createdAt) }} · {{ people().name(r.createdBy) }}</span>
3525
+ </div>
3526
+ <div class="o-cb">
3527
+ @if (r.title && r.title !== r.tag) {<h4>{{ r.title }}</h4>}
3528
+ <div class="o-md" style="font-size:13px" [innerHTML]="r.notes ? md(r.notes) : '<p class=&quot;o-faint&quot;>No notes.</p>'"></div>
3529
+ </div>
3530
+ <div class="o-cf assets">
3531
+ <span>Assets</span>
3532
+ @for (a of r.assets; track a.id) {
3533
+ <button type="button" class="o-lnk o-mono" (click)="download(r, a.id, a.name)" [attr.aria-label]="'Download ' + a.name">{{ a.name }}</button>
3534
+ @if (canWrite()) {<button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="removeAsset(r, a.id)" [attr.aria-label]="'Remove ' + a.name">×</button>}
3535
+ }
3536
+ <button type="button" class="o-lnk" (click)="archive(r, 'zip')">Source (zip)</button>
3537
+ <button type="button" class="o-lnk" (click)="archive(r, 'tar.gz')">Source (tar.gz)</button>
3538
+ @if (canWrite()) {
3539
+ <span class="o-r o-flex" style="gap:10px">
3540
+ <label class="o-lnk" style="cursor:pointer">Add file<input type="file" class="o-sr-only" (change)="upload(r, $event)" [attr.aria-label]="'Add a file to ' + r.tag" /></label>
3541
+ <button type="button" class="o-lnk" (click)="edit(r)" [attr.aria-label]="'Edit ' + r.tag">Edit</button>
3542
+ @if (confirming() === r.id) {
3543
+ <label class="o-flex o-xs"><input type="checkbox" [(ngModel)]="alsoTag" /> and the tag</label>
3544
+ <button type="button" class="o-lnk o-red-t" (click)="remove(r)">Delete</button><button type="button" class="o-lnk" (click)="confirming.set('')">Keep</button>
3545
+ } @else {
3546
+ <button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="confirming.set(r.id); alsoTag = false" [attr.aria-label]="'Delete ' + r.tag">Delete</button>
3547
+ }
3548
+ </span>
3549
+ }
3550
+ </div>
3551
+ </div>
3552
+ } @empty {
3553
+ <div class="o-card"><div class="o-empty">{{ loaded() ? 'No releases yet.' + (canWrite() ? ' Draft one from a tag; its notes can come from the issues it finishes.' : '') : 'Loading…' }}</div></div>
3554
+ }
3555
+
3556
+ <div class="o-card" data-testid="tags">
3557
+ <div class="o-ch"><b>Tags</b><span class="o-r o-faint o-sm">{{ tags().length }}</span></div>
3558
+ @for (t of tags(); track t.name) {
3559
+ <div class="o-row" [attr.data-testid]="'tag-' + t.name">
3560
+ <span class="o-mono o-sm o-b5">{{ t.name }}</span><span class="o-faint o-sm o-mono">{{ t.commit?.short }}</span>
3561
+ <span class="o-r o-faint o-sm">{{ ago(t.commit?.committer?.when) }}</span>
3562
+ @if (canWrite() && !used(t.name)) {<button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="removeTag(t)" [attr.aria-label]="'Delete tag ' + t.name">Delete</button>}
3563
+ </div>
3564
+ } @empty {<div class="o-empty o-sm">No tags yet.</div>}
3565
+ </div>
3566
+ </div>
3567
+ `, isInline: true, styles: [".rel h4{margin:0 0 6px;font-size:15px}.rel .o-md ul{margin:0;padding-left:20px}.assets{display:flex;gap:12px;flex-wrap:wrap;align-items:center}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { 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.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
3568
+ }
3569
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ReleasesPage, decorators: [{
3570
+ type: Component,
3571
+ args: [{ selector: 'o-code-releases', imports: [FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
3572
+ <div class="o-page o-narrow">
3573
+ <div class="o-ph">
3574
+ <div class="o-col"><span class="o-eyebrow">{{ name() }}</span><h1 class="o-t">Releases and tags</h1></div>
3575
+ @if (canWrite()) {
3576
+ <div class="o-acts">
3577
+ <button type="button" class="o-btn o-sm" (click)="tagging.set(!tagging()); draft.set(null)" data-testid="tag-new">New tag</button>
3578
+ <button type="button" class="o-btn o-sm o-dark" (click)="startRelease()" data-testid="release-new">Draft release</button>
3579
+ </div>
3580
+ }
3581
+ </div>
3582
+ @if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
3583
+ @if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
3584
+
3585
+ @if (tagging()) {
3586
+ <form class="o-card" (ngSubmit)="createTag()" data-testid="tag-form">
3587
+ <div class="o-ch"><b>New tag</b></div>
3588
+ <div class="o-cb o-flex o-wrap" style="align-items:flex-end">
3589
+ <div class="o-fld"><label for="tg-name">Tag</label><input id="tg-name" class="o-in o-mono" name="tag" placeholder="v1.8.0" [(ngModel)]="tagName" /></div>
3590
+ <div class="o-fld"><label for="tg-from">At</label><input id="tg-from" class="o-in o-mono" name="from" [(ngModel)]="tagFrom" placeholder="main or a commit" /></div>
3591
+ <button type="button" class="o-btn o-sm o-r" (click)="tagging.set(false)">Cancel</button>
3592
+ <button type="submit" class="o-btn o-sm o-dark" [disabled]="busy() || !tagName.trim()">Create tag</button>
3593
+ </div>
3594
+ </form>
3595
+ }
3596
+
3597
+ @if (draft(); as d) {
3598
+ <div class="o-card" data-testid="release-form">
3599
+ <div class="o-ch"><b>{{ d.id ? 'Edit ' + d.tag : 'Draft a release' }}</b></div>
3600
+ <div class="o-cb o-col" style="gap:12px">
3601
+ @if (!d.id) {
3602
+ <div class="o-flex o-wrap" style="align-items:flex-end">
3603
+ <div class="o-fld"><label for="rl-tag">Tag</label>
3604
+ <input id="rl-tag" class="o-in o-mono" list="rl-tags" placeholder="v1.8.0" [ngModel]="d.tag" (ngModelChange)="setTag(d, $event)" data-testid="release-tag" />
3605
+ <datalist id="rl-tags">@for (t of freeTags(); track t.name) {<option [value]="t.name"></option>}</datalist>
3606
+ </div>
3607
+ @if (d.newTag) {<div class="o-fld"><label for="rl-target">New tag at</label><input id="rl-target" class="o-in o-mono" [(ngModel)]="d.target" placeholder="main" /></div>}
3608
+ <span class="o-hint" style="padding-bottom:8px">{{ d.tag ? (d.newTag ? 'A new tag, made when you save.' : 'An existing tag.') : '' }}</span>
3609
+ </div>
3610
+ }
3611
+ <div class="o-fld"><label for="rl-title">Title</label><input id="rl-title" class="o-in" maxlength="255" [(ngModel)]="d.title" placeholder="Defaults to the tag" data-testid="release-title" /></div>
3612
+ <div class="o-fld">
3613
+ <div class="o-flex"><label for="rl-notes">Notes</label>
3614
+ @if (!d.id) {<button type="button" class="o-lnk o-r o-sm" [disabled]="busy() || !d.tag" (click)="generate(d)" data-testid="release-generate">Generate from done issues</button>}
3615
+ </div>
3616
+ <textarea id="rl-notes" class="o-in" rows="7" style="height:auto;padding:8px 10px" [(ngModel)]="d.notes" placeholder="Markdown works." data-testid="release-notes"></textarea>
3617
+ <span class="o-hint">Generated notes list the done issues and merged pull requests since the previous release.</span>
3618
+ </div>
3619
+ <label class="o-flex o-sm"><input type="checkbox" [(ngModel)]="d.prerelease" /> Pre-release (never marked Latest)</label>
3620
+ </div>
3621
+ <div class="o-cf">
3622
+ <button type="button" class="o-btn o-sm o-r" (click)="draft.set(null)">Cancel</button>
3623
+ @if (!d.id || d.draft) {<button type="button" class="o-btn o-sm" [disabled]="busy() || !d.tag" (click)="save(d, true)" data-testid="release-save-draft">Save draft</button>}
3624
+ <button type="button" class="o-btn o-sm o-dark" [disabled]="busy() || !d.tag" (click)="save(d, false)" data-testid="release-publish">{{ d.id && !d.draft ? 'Save' : 'Publish release' }}</button>
3625
+ </div>
3626
+ </div>
3627
+ }
3628
+
3629
+ @for (r of releases(); track r.id) {
3630
+ <div class="o-card rel" [attr.data-testid]="'release-' + r.tag">
3631
+ <div class="o-ch">
3632
+ <b class="o-md">{{ r.tag }}</b>
3633
+ @if (r.latest) {<span class="o-chip o-grn">Latest</span>}
3634
+ @if (r.draft) {<span class="o-chip o-amb">Draft</span>}
3635
+ @if (r.prerelease) {<span class="o-chip">Pre-release</span>}
3636
+ <span class="o-r o-mono o-sm o-faint">{{ r.short }} · {{ ago(r.publishedAt || r.createdAt) }} · {{ people().name(r.createdBy) }}</span>
3637
+ </div>
3638
+ <div class="o-cb">
3639
+ @if (r.title && r.title !== r.tag) {<h4>{{ r.title }}</h4>}
3640
+ <div class="o-md" style="font-size:13px" [innerHTML]="r.notes ? md(r.notes) : '<p class=&quot;o-faint&quot;>No notes.</p>'"></div>
3641
+ </div>
3642
+ <div class="o-cf assets">
3643
+ <span>Assets</span>
3644
+ @for (a of r.assets; track a.id) {
3645
+ <button type="button" class="o-lnk o-mono" (click)="download(r, a.id, a.name)" [attr.aria-label]="'Download ' + a.name">{{ a.name }}</button>
3646
+ @if (canWrite()) {<button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="removeAsset(r, a.id)" [attr.aria-label]="'Remove ' + a.name">×</button>}
3647
+ }
3648
+ <button type="button" class="o-lnk" (click)="archive(r, 'zip')">Source (zip)</button>
3649
+ <button type="button" class="o-lnk" (click)="archive(r, 'tar.gz')">Source (tar.gz)</button>
3650
+ @if (canWrite()) {
3651
+ <span class="o-r o-flex" style="gap:10px">
3652
+ <label class="o-lnk" style="cursor:pointer">Add file<input type="file" class="o-sr-only" (change)="upload(r, $event)" [attr.aria-label]="'Add a file to ' + r.tag" /></label>
3653
+ <button type="button" class="o-lnk" (click)="edit(r)" [attr.aria-label]="'Edit ' + r.tag">Edit</button>
3654
+ @if (confirming() === r.id) {
3655
+ <label class="o-flex o-xs"><input type="checkbox" [(ngModel)]="alsoTag" /> and the tag</label>
3656
+ <button type="button" class="o-lnk o-red-t" (click)="remove(r)">Delete</button><button type="button" class="o-lnk" (click)="confirming.set('')">Keep</button>
3657
+ } @else {
3658
+ <button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="confirming.set(r.id); alsoTag = false" [attr.aria-label]="'Delete ' + r.tag">Delete</button>
3659
+ }
3660
+ </span>
3661
+ }
3662
+ </div>
3663
+ </div>
3664
+ } @empty {
3665
+ <div class="o-card"><div class="o-empty">{{ loaded() ? 'No releases yet.' + (canWrite() ? ' Draft one from a tag; its notes can come from the issues it finishes.' : '') : 'Loading…' }}</div></div>
3666
+ }
3667
+
3668
+ <div class="o-card" data-testid="tags">
3669
+ <div class="o-ch"><b>Tags</b><span class="o-r o-faint o-sm">{{ tags().length }}</span></div>
3670
+ @for (t of tags(); track t.name) {
3671
+ <div class="o-row" [attr.data-testid]="'tag-' + t.name">
3672
+ <span class="o-mono o-sm o-b5">{{ t.name }}</span><span class="o-faint o-sm o-mono">{{ t.commit?.short }}</span>
3673
+ <span class="o-r o-faint o-sm">{{ ago(t.commit?.committer?.when) }}</span>
3674
+ @if (canWrite() && !used(t.name)) {<button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="removeTag(t)" [attr.aria-label]="'Delete tag ' + t.name">Delete</button>}
3675
+ </div>
3676
+ } @empty {<div class="o-empty o-sm">No tags yet.</div>}
3677
+ </div>
3678
+ </div>
3679
+ `, styles: [".rel h4{margin:0 0 6px;font-size:15px}.rel .o-md ul{margin:0;padding-left:20px}.assets{display:flex;gap:12px;flex-wrap:wrap;align-items:center}\n"] }]
3680
+ }] });
3681
+ /** Newest commit first; tags on commits of the same second by version, highest first (v1.10 after v1.9). */
3682
+ function sortTags(tags) {
3683
+ const when = (t) => Date.parse(t.commit?.committer?.when ?? '') || 0;
3684
+ return [...tags].sort((a, b) => when(b) - when(a) || b.name.localeCompare(a.name, undefined, { numeric: true }));
3685
+ }
3686
+
2958
3687
  /*
2959
3688
  * @chisquare-tech/origine-ngx-apps-code: the Code module's screens (TRD §6.10). The host app provides
2960
3689
  * CodeHost and mounts the pages at /{org}/{project}/code, /code/new, /code/pulls and
@@ -2965,5 +3694,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2965
3694
  * Generated bundle index. Do not edit.
2966
3695
  */
2967
3696
 
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 };
3697
+ export { BranchesPage, CodeApi, CodeHost, CommitPage, CommitsPage, CurrentRepo, CurrentRepoPage, DiffView, FilePage, NewRepoPage, People, PullPage, PullsPage, RefPicker, ReleasesPage, RepoHomePage, RepoPage, RepoSettingsPage, ReposPage, TOKEN_CSS, ago, apiMessage, checkDot, copy, dayLabel, initials, langColour, pair, shortDate, size };
2969
3698
  //# sourceMappingURL=chisquare-tech-origine-ngx-apps-code.mjs.map