@chisquare-tech/origine-ngx-apps-code 0.3.0 → 0.5.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.
@@ -65,8 +65,15 @@ class CodeApi {
65
65
  tree(project, name, ref, path) {
66
66
  return this.api.get(`${this.repoPath(project, name)}/tree`, { ref, path }).pipe(map((r) => data(r)));
67
67
  }
68
- blob(project, name, ref, path) {
69
- return this.api.get(`${this.repoPath(project, name)}/blob`, { ref, path }).pipe(map((r) => data(r)));
68
+ blob(project, name, ref, path, withText = false) {
69
+ const p = { ref, path };
70
+ if (withText)
71
+ p['format'] = 'text';
72
+ return this.api.get(`${this.repoPath(project, name)}/blob`, p).pipe(map((r) => data(r)));
73
+ }
74
+ /** A commit from the browser (DEV-13): files, base64 uploads and deletes; from a new branch it can open a pull request. */
75
+ commitFiles(project, name, body) {
76
+ return this.api.post(`${this.repoPath(project, name)}/commits`, body).pipe(map((r) => data(r)));
70
77
  }
71
78
  blame(project, name, ref, path) {
72
79
  return this.api.get(`${this.repoPath(project, name)}/blame`, { ref, path }).pipe(map((r) => data(r) ?? []));
@@ -115,6 +122,15 @@ class CodeApi {
115
122
  p['q'] = q;
116
123
  return this.api.get(`${this.repoPath(project, name)}/refs`, p).pipe(map((r) => data(r) ?? []));
117
124
  }
125
+ collaborators(project, name) {
126
+ return this.api.get(`${this.repoPath(project, name)}/collaborators`).pipe(map((r) => data(r) ?? []));
127
+ }
128
+ setCollaborator(project, name, userId, permission) {
129
+ return this.api.put(`${this.repoPath(project, name)}/collaborators/${enc(userId)}`, { permission }).pipe(map((r) => data(r)));
130
+ }
131
+ removeCollaborator(project, name, userId) {
132
+ return this.api.delete(`${this.repoPath(project, name)}/collaborators/${enc(userId)}`);
133
+ }
118
134
  createTag(project, name, tag, target) {
119
135
  return this.api.post(`${this.repoPath(project, name)}/tags`, { tag, target }).pipe(map((r) => data(r)));
120
136
  }
@@ -343,6 +359,8 @@ class RepoPage {
343
359
  this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
344
360
  this.canWrite = computed(() => !this.repo()?.archivedAt && (this.repo()?.capabilities ?? []).includes('code.write'), ...(ngDevMode ? [{ debugName: "canWrite" }] : /* istanbul ignore next */ []));
345
361
  this.canAdmin = computed(() => (this.repo()?.capabilities ?? []).includes('resources.manage'), ...(ngDevMode ? [{ debugName: "canAdmin" }] : /* istanbul ignore next */ []));
362
+ /** Repository settings short of a rename, the visibility and deletion: Maintainers, and maintain collaborators. */
363
+ this.canMaintain = computed(() => this.canAdmin() || (this.repo()?.capabilities ?? []).includes('repo.maintain'), ...(ngDevMode ? [{ debugName: "canMaintain" }] : /* istanbul ignore next */ []));
346
364
  this.canProtect = computed(() => (this.repo()?.capabilities ?? []).includes('protection.manage'), ...(ngDevMode ? [{ debugName: "canProtect" }] : /* istanbul ignore next */ []));
347
365
  this.route.paramMap.subscribe((m) => this.name.set(m.get('repo') ?? ''));
348
366
  effect(() => {
@@ -1215,6 +1233,7 @@ class RepoHomePage extends RepoPage {
1215
1233
  this.path = signal('', ...(ngDevMode ? [{ debugName: "path" }] : /* istanbul ignore next */ []));
1216
1234
  this.proto = signal('https', ...(ngDevMode ? [{ debugName: "proto" }] : /* istanbul ignore next */ []));
1217
1235
  this.cloneOpen = signal(false, ...(ngDevMode ? [{ debugName: "cloneOpen" }] : /* istanbul ignore next */ []));
1236
+ this.addOpen = signal(false, ...(ngDevMode ? [{ debugName: "addOpen" }] : /* istanbul ignore next */ []));
1218
1237
  this.copied = signal(false, ...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
1219
1238
  this.finding = signal(false, ...(ngDevMode ? [{ debugName: "finding" }] : /* istanbul ignore next */ []));
1220
1239
  this.fq = signal('', ...(ngDevMode ? [{ debugName: "fq" }] : /* istanbul ignore next */ []));
@@ -1343,6 +1362,17 @@ class RepoHomePage extends RepoPage {
1343
1362
  </nav>
1344
1363
  <span class="o-r"></span>
1345
1364
  <button type="button" class="o-btn o-sm" (click)="openFinder()">Go to file <span class="o-kbd">T</span></button>
1365
+ @if (canWrite()) {
1366
+ <span class="anchor add">
1367
+ <button type="button" class="o-btn o-sm" (click)="addOpen.set(!addOpen())" [attr.aria-expanded]="addOpen()" aria-haspopup="menu" data-testid="add-file">Add file ⌄</button>
1368
+ @if (addOpen()) {
1369
+ <div class="o-menu" role="menu" style="right:0;top:34px;width:200px">
1370
+ <a role="menuitem" [routerLink]="link('new')" [queryParams]="{ ref: ref() || null, path: path() || null }" data-testid="add-new">Create new file</a>
1371
+ <a role="menuitem" [routerLink]="link('upload')" [queryParams]="{ ref: ref() || null, path: path() || null }" data-testid="add-upload">Upload files</a>
1372
+ </div>
1373
+ }
1374
+ </span>
1375
+ }
1346
1376
  <span class="anchor">
1347
1377
  <button type="button" class="o-btn o-sm o-pri" (click)="cloneOpen.set(!cloneOpen())" [attr.aria-expanded]="cloneOpen()" aria-haspopup="dialog" data-testid="clone-menu">Code ⌄</button>
1348
1378
  @if (cloneOpen()) {
@@ -1463,6 +1493,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1463
1493
  </nav>
1464
1494
  <span class="o-r"></span>
1465
1495
  <button type="button" class="o-btn o-sm" (click)="openFinder()">Go to file <span class="o-kbd">T</span></button>
1496
+ @if (canWrite()) {
1497
+ <span class="anchor add">
1498
+ <button type="button" class="o-btn o-sm" (click)="addOpen.set(!addOpen())" [attr.aria-expanded]="addOpen()" aria-haspopup="menu" data-testid="add-file">Add file ⌄</button>
1499
+ @if (addOpen()) {
1500
+ <div class="o-menu" role="menu" style="right:0;top:34px;width:200px">
1501
+ <a role="menuitem" [routerLink]="link('new')" [queryParams]="{ ref: ref() || null, path: path() || null }" data-testid="add-new">Create new file</a>
1502
+ <a role="menuitem" [routerLink]="link('upload')" [queryParams]="{ ref: ref() || null, path: path() || null }" data-testid="add-upload">Upload files</a>
1503
+ </div>
1504
+ }
1505
+ </span>
1506
+ }
1466
1507
  <span class="anchor">
1467
1508
  <button type="button" class="o-btn o-sm o-pri" (click)="cloneOpen.set(!cloneOpen())" [attr.aria-expanded]="cloneOpen()" aria-haspopup="dialog" data-testid="clone-menu">Code ⌄</button>
1468
1509
  @if (cloneOpen()) {
@@ -1546,6 +1587,109 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1546
1587
  args: ['document:keydown', ['$event']]
1547
1588
  }] } });
1548
1589
 
1590
+ /** Whether a branch protection pattern names the branch (as code's protect.Match: * within a segment, ** across). */
1591
+ function matchesPattern(pattern, branch) {
1592
+ const seg = (p) => '^' + p.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*') + '$';
1593
+ const pat = pattern.split('/');
1594
+ const name = branch.split('/');
1595
+ const go = (i, j) => {
1596
+ if (i === pat.length)
1597
+ return j === name.length;
1598
+ if (pat[i] === '**') {
1599
+ if (i === pat.length - 1)
1600
+ return j < name.length;
1601
+ for (let k = j; k <= name.length; k++)
1602
+ if (go(i + 1, k))
1603
+ return true;
1604
+ return false;
1605
+ }
1606
+ return j < name.length && new RegExp(seg(pat[i])).test(name[j]) && go(i + 1, j + 1);
1607
+ };
1608
+ return go(0, 0);
1609
+ }
1610
+ // The web editor's commit box (prototype code_file "Commit and open PR", DEV-13): a message, and
1611
+ // either the branch itself or a new branch with a pull request. A branch whose rules block direct
1612
+ // pushes only offers the second.
1613
+ class CommitBox {
1614
+ constructor() {
1615
+ this.api = inject(CodeApi);
1616
+ this.host = inject(CodeHost);
1617
+ this.repo = input.required(...(ngDevMode ? [{ debugName: "repo" }] : /* istanbul ignore next */ []));
1618
+ this.branch = input.required(...(ngDevMode ? [{ debugName: "branch" }] : /* istanbul ignore next */ []));
1619
+ this.defaultMessage = input('', ...(ngDevMode ? [{ debugName: "defaultMessage" }] : /* istanbul ignore next */ []));
1620
+ this.busy = input(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
1621
+ this.commit = output();
1622
+ this.cancel = output();
1623
+ this.message = signal('', ...(ngDevMode ? [{ debugName: "message" }] : /* istanbul ignore next */ []));
1624
+ this.toNew = signal(false, ...(ngDevMode ? [{ debugName: "toNew" }] : /* istanbul ignore next */ []));
1625
+ this.newBranch = signal('', ...(ngDevMode ? [{ debugName: "newBranch" }] : /* istanbul ignore next */ []));
1626
+ this.rules = signal([], ...(ngDevMode ? [{ debugName: "rules" }] : /* istanbul ignore next */ []));
1627
+ this.blocked = computed(() => this.rules().some((r) => r.blockDirectPush && matchesPattern(r.pattern, this.branch())), ...(ngDevMode ? [{ debugName: "blocked" }] : /* istanbul ignore next */ []));
1628
+ effect(() => {
1629
+ const m = this.defaultMessage();
1630
+ untracked(() => this.message.set(m));
1631
+ });
1632
+ effect(() => {
1633
+ const repo = this.repo();
1634
+ const p = this.host.project();
1635
+ if (!p || !repo)
1636
+ return;
1637
+ untracked(() => {
1638
+ this.api.protections(p.id, repo).subscribe({ next: (r) => this.rules.set(r), error: () => undefined });
1639
+ const me = (this.host.people().find((x) => x.id === this.host.meId())?.name || 'patch').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'patch';
1640
+ this.newBranch.set(`${me}/patch-${Math.floor(Date.now() / 1000) % 100000}`);
1641
+ });
1642
+ });
1643
+ }
1644
+ send() {
1645
+ const toNew = this.toNew() || this.blocked();
1646
+ this.commit.emit({ message: this.message().trim(), newBranch: toNew ? this.newBranch().trim() : undefined });
1647
+ }
1648
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: CommitBox, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
1649
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: CommitBox, isStandalone: true, selector: "o-commit-box", inputs: { repo: { classPropertyName: "repo", publicName: "repo", isSignal: true, isRequired: true, transformFunction: null }, branch: { classPropertyName: "branch", publicName: "branch", isSignal: true, isRequired: true, transformFunction: null }, defaultMessage: { classPropertyName: "defaultMessage", publicName: "defaultMessage", isSignal: true, isRequired: false, transformFunction: null }, busy: { classPropertyName: "busy", publicName: "busy", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { commit: "commit", cancel: "cancel" }, ngImport: i0, template: `
1650
+ <div class="o-col" style="gap:10px" data-testid="commit-box">
1651
+ @if (blocked()) {<div class="o-note o-blu">Your change is committed to a new branch with a pull request because <b>{{ branch() }}</b> is protected.</div>}
1652
+ <div class="o-fld"><label for="cb-msg">Commit message</label><input id="cb-msg" class="o-in" [ngModel]="message()" (ngModelChange)="message.set($event)" data-testid="commit-message" /></div>
1653
+ @if (!blocked()) {
1654
+ <label class="o-flex o-sm"><input type="radio" name="cb-where" [checked]="!toNew()" (change)="toNew.set(false)" /> Commit directly to <b class="o-mono">{{ branch() }}</b></label>
1655
+ <label class="o-flex o-sm"><input type="radio" name="cb-where" [checked]="toNew()" (change)="toNew.set(true)" data-testid="commit-new-branch" /> Create a new branch and open a pull request</label>
1656
+ }
1657
+ @if (toNew() || blocked()) {
1658
+ <div class="o-flex o-sm"><span class="o-faint">→ new branch</span><input class="o-in o-sm o-mono" style="max-width:260px" aria-label="New branch" [ngModel]="newBranch()" (ngModelChange)="newBranch.set($event)" data-testid="commit-branch" /></div>
1659
+ }
1660
+ <div class="o-flex">
1661
+ <button type="button" class="o-btn o-sm o-r" (click)="cancel.emit()">Cancel</button>
1662
+ <button type="button" class="o-btn o-sm o-pri" [disabled]="busy() || !message().trim() || ((toNew() || blocked()) && !newBranch().trim())" (click)="send()" data-testid="commit-submit">{{ toNew() || blocked() ? 'Commit and open PR' : 'Commit changes' }}</button>
1663
+ </div>
1664
+ </div>
1665
+ `, isInline: true, 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 }); }
1666
+ }
1667
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: CommitBox, decorators: [{
1668
+ type: Component,
1669
+ args: [{
1670
+ selector: 'o-commit-box',
1671
+ imports: [FormsModule],
1672
+ changeDetection: ChangeDetectionStrategy.OnPush,
1673
+ template: `
1674
+ <div class="o-col" style="gap:10px" data-testid="commit-box">
1675
+ @if (blocked()) {<div class="o-note o-blu">Your change is committed to a new branch with a pull request because <b>{{ branch() }}</b> is protected.</div>}
1676
+ <div class="o-fld"><label for="cb-msg">Commit message</label><input id="cb-msg" class="o-in" [ngModel]="message()" (ngModelChange)="message.set($event)" data-testid="commit-message" /></div>
1677
+ @if (!blocked()) {
1678
+ <label class="o-flex o-sm"><input type="radio" name="cb-where" [checked]="!toNew()" (change)="toNew.set(false)" /> Commit directly to <b class="o-mono">{{ branch() }}</b></label>
1679
+ <label class="o-flex o-sm"><input type="radio" name="cb-where" [checked]="toNew()" (change)="toNew.set(true)" data-testid="commit-new-branch" /> Create a new branch and open a pull request</label>
1680
+ }
1681
+ @if (toNew() || blocked()) {
1682
+ <div class="o-flex o-sm"><span class="o-faint">→ new branch</span><input class="o-in o-sm o-mono" style="max-width:260px" aria-label="New branch" [ngModel]="newBranch()" (ngModelChange)="newBranch.set($event)" data-testid="commit-branch" /></div>
1683
+ }
1684
+ <div class="o-flex">
1685
+ <button type="button" class="o-btn o-sm o-r" (click)="cancel.emit()">Cancel</button>
1686
+ <button type="button" class="o-btn o-sm o-pri" [disabled]="busy() || !message().trim() || ((toNew() || blocked()) && !newBranch().trim())" (click)="send()" data-testid="commit-submit">{{ toNew() || blocked() ? 'Commit and open PR' : 'Commit changes' }}</button>
1687
+ </div>
1688
+ </div>
1689
+ `,
1690
+ }]
1691
+ }], ctorParameters: () => [], propDecorators: { repo: [{ type: i0.Input, args: [{ isSignal: true, alias: "repo", required: true }] }], branch: [{ type: i0.Input, args: [{ isSignal: true, alias: "branch", required: true }] }], defaultMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultMessage", required: false }] }], busy: [{ type: i0.Input, args: [{ isSignal: true, alias: "busy", required: false }] }], commit: [{ type: i0.Output, args: ["commit"] }], cancel: [{ type: i0.Output, args: ["cancel"] }] } });
1692
+
1549
1693
  // /{org}/{project}/code/{repo}/blob?ref=&path= (prototype "code_file"): one file, highlighted, with
1550
1694
  // Markdown preview, blame, raw, copy and download; a line number links to that line (#L12).
1551
1695
  class FilePage extends RepoPage {
@@ -1572,6 +1716,17 @@ class FilePage extends RepoPage {
1572
1716
  return '';
1573
1717
  return [b.binary ? '' : `${b.lineCount} lines`, size(b.size), b.language ?? ''].filter(Boolean).join(' · ');
1574
1718
  }, ...(ngDevMode ? [{ debugName: "meta" }] : /* istanbul ignore next */ []));
1719
+ this.mode = signal('', ...(ngDevMode ? [{ debugName: "mode" }] : /* istanbul ignore next */ []));
1720
+ this.text = signal(null, ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
1721
+ this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
1722
+ this.branches = signal([], ...(ngDevMode ? [{ debugName: "branches" }] : /* istanbul ignore next */ []));
1723
+ /** The branch the view is on ('' for a tag or a commit, which cannot take commits). */
1724
+ this.branch = computed(() => {
1725
+ const r = this.repo();
1726
+ const ref = this.ref() || r?.defaultBranch || '';
1727
+ return this.branches().includes(ref) ? ref : '';
1728
+ }, ...(ngDevMode ? [{ debugName: "branch" }] : /* istanbul ignore next */ []));
1729
+ this.editable = computed(() => this.canWrite() && !!this.branch(), ...(ngDevMode ? [{ debugName: "editable" }] : /* istanbul ignore next */ []));
1575
1730
  this.route.queryParamMap.subscribe((q) => {
1576
1731
  this.ref.set(q.get('ref') ?? '');
1577
1732
  this.path.set(q.get('path') ?? '');
@@ -1583,6 +1738,60 @@ class FilePage extends RepoPage {
1583
1738
  }
1584
1739
  onRepo(r) {
1585
1740
  this.load(r);
1741
+ this.api.refs(this.projectId(), r.name, 'branch').subscribe({ next: (b) => this.branches.set(b.map((x) => x.name)), error: () => undefined });
1742
+ }
1743
+ startEdit() {
1744
+ const r = this.repo();
1745
+ if (!r)
1746
+ return;
1747
+ this.mode.set('edit');
1748
+ this.text.set(null);
1749
+ this.api.blob(this.projectId(), r.name, this.branch(), this.path(), true).subscribe({
1750
+ next: (b) => {
1751
+ if (b.text === undefined) {
1752
+ this.mode.set('');
1753
+ this.error.set('This file cannot be edited in the browser.');
1754
+ return;
1755
+ }
1756
+ this.blob.set(b);
1757
+ this.text.set(b.text);
1758
+ },
1759
+ error: (e) => { this.mode.set(''); this.error.set(apiMessage(e, 'Could not open the file.')); },
1760
+ });
1761
+ }
1762
+ /** Commits the edit (or the delete), onto the branch or onto a new branch with a pull request. */
1763
+ save(c, del = false) {
1764
+ const r = this.repo();
1765
+ const b = this.blob();
1766
+ if (!r || !b)
1767
+ return;
1768
+ const body = c.newBranch
1769
+ ? { branch: c.newBranch, from: this.branch(), message: c.message, pull: {} }
1770
+ : { branch: this.branch(), expect: b.commit, message: c.message };
1771
+ if (del)
1772
+ body.deletes = [b.path];
1773
+ else
1774
+ body.files = { [b.path]: this.text() ?? '' };
1775
+ this.busy.set(true);
1776
+ this.error.set('');
1777
+ this.api.commitFiles(this.projectId(), r.name, body).subscribe({
1778
+ next: (res) => {
1779
+ this.busy.set(false);
1780
+ this.mode.set('');
1781
+ if (res.pull) {
1782
+ void this.router.navigate(this.link('pull/' + res.pull));
1783
+ return;
1784
+ }
1785
+ if (del) {
1786
+ void this.router.navigate(this.link(), { queryParams: { ref: res.branch, path: b.path.split('/').slice(0, -1).join('/') || null } });
1787
+ return;
1788
+ }
1789
+ this.note.set(`Committed ${res.commit.short} to ${res.branch}.`);
1790
+ void this.router.navigate(this.link('blob'), { queryParams: { ref: res.branch, path: b.path } });
1791
+ this.load(r);
1792
+ },
1793
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not commit.')); },
1794
+ });
1586
1795
  }
1587
1796
  pickRef(ref) {
1588
1797
  void this.router.navigate(this.link('blob'), { queryParams: { ref, path: this.path() } });
@@ -1640,6 +1849,7 @@ class FilePage extends RepoPage {
1640
1849
  </nav>
1641
1850
  </div>
1642
1851
  @if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
1852
+ @if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
1643
1853
  @if (blob(); as b) {
1644
1854
  <div class="o-card">
1645
1855
  @if (b.lastCommit; as lc) {
@@ -1659,8 +1869,26 @@ class FilePage extends RepoPage {
1659
1869
  @if (b.rawUrl) {<a class="o-btn o-xs" [href]="b.rawUrl" target="_blank" rel="noopener">Raw</a>}
1660
1870
  @if (!b.binary) {<button type="button" class="o-btn o-xs" (click)="copyText()">{{ copied() ? 'Copied' : 'Copy' }}</button>}
1661
1871
  @if (b.downloadUrl) {<a class="o-btn o-xs" [href]="b.downloadUrl">Download</a>}
1872
+ @if (editable()) {
1873
+ @if (!b.binary && !b.truncated) {<button type="button" class="o-btn o-xs" (click)="startEdit()" [disabled]="mode() !== ''" data-testid="file-edit">Edit</button>}
1874
+ <button type="button" class="o-btn o-xs" (click)="mode.set('delete')" [disabled]="mode() !== ''" data-testid="file-delete">Delete</button>
1875
+ }
1662
1876
  </span>
1663
1877
  </div>
1878
+ @if (mode() === 'edit') {
1879
+ <div class="o-cb o-col" style="gap:10px;background:var(--o-well);border-bottom:1px solid var(--o-line-soft)">
1880
+ @if (text() === null) {<span class="o-faint o-sm">Loading the file…</span>} @else {
1881
+ <textarea class="o-in o-mono" rows="16" style="font-size:12px;height:auto;padding:8px 10px" aria-label="File contents" [ngModel]="text()" (ngModelChange)="text.set($event)" data-testid="file-text"></textarea>
1882
+ <o-commit-box [repo]="r.name" [branch]="branch()" [defaultMessage]="'Update ' + b.name" [busy]="busy()" (commit)="save($event)" (cancel)="mode.set('')" />
1883
+ }
1884
+ </div>
1885
+ }
1886
+ @if (mode() === 'delete') {
1887
+ <div class="o-cb o-col" style="gap:10px;background:var(--o-well);border-bottom:1px solid var(--o-line-soft)">
1888
+ <span class="o-sm">Delete <b class="o-mono">{{ b.path }}</b> from <b class="o-mono">{{ branch() }}</b>?</span>
1889
+ <o-commit-box [repo]="r.name" [branch]="branch()" [defaultMessage]="'Delete ' + b.name" [busy]="busy()" (commit)="save($event, true)" (cancel)="mode.set('')" />
1890
+ </div>
1891
+ }
1664
1892
  @if (b.image && b.rawUrl) {
1665
1893
  <div class="o-cb" style="text-align:center"><img [src]="b.rawUrl" [alt]="b.name" style="max-width:100%" /></div>
1666
1894
  } @else if (b.binary || b.truncated) {
@@ -1687,11 +1915,11 @@ class FilePage extends RepoPage {
1687
1915
  <div class="o-card o-empty"><p>{{ error() }}</p></div>
1688
1916
  }
1689
1917
  </div>
1690
- `, 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{text-decoration:none}.o-btn.o-on{background:var(--o-tx);border-color:var(--o-tx);color:var(--o-acc-fg)}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: RefPicker, selector: "o-ref-picker", inputs: ["repo", "current"], outputs: ["picked"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1918
+ `, 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{text-decoration:none}.o-btn.o-on{background:var(--o-tx);border-color:var(--o-tx);color:var(--o-acc-fg)}\n"], dependencies: [{ kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: RefPicker, selector: "o-ref-picker", inputs: ["repo", "current"], outputs: ["picked"] }, { 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: "component", type: CommitBox, selector: "o-commit-box", inputs: ["repo", "branch", "defaultMessage", "busy"], outputs: ["commit", "cancel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1691
1919
  }
1692
1920
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: FilePage, decorators: [{
1693
1921
  type: Component,
1694
- args: [{ selector: 'o-code-file', imports: [RouterLink, RefPicker], changeDetection: ChangeDetectionStrategy.OnPush, template: `
1922
+ args: [{ selector: 'o-code-file', imports: [RouterLink, RefPicker, FormsModule, CommitBox], changeDetection: ChangeDetectionStrategy.OnPush, template: `
1695
1923
  <div class="o-page o-wide">
1696
1924
  @if (repo(); as r) {
1697
1925
  <div class="o-flex o-wrap">
@@ -1705,6 +1933,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1705
1933
  </nav>
1706
1934
  </div>
1707
1935
  @if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
1936
+ @if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
1708
1937
  @if (blob(); as b) {
1709
1938
  <div class="o-card">
1710
1939
  @if (b.lastCommit; as lc) {
@@ -1724,8 +1953,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1724
1953
  @if (b.rawUrl) {<a class="o-btn o-xs" [href]="b.rawUrl" target="_blank" rel="noopener">Raw</a>}
1725
1954
  @if (!b.binary) {<button type="button" class="o-btn o-xs" (click)="copyText()">{{ copied() ? 'Copied' : 'Copy' }}</button>}
1726
1955
  @if (b.downloadUrl) {<a class="o-btn o-xs" [href]="b.downloadUrl">Download</a>}
1956
+ @if (editable()) {
1957
+ @if (!b.binary && !b.truncated) {<button type="button" class="o-btn o-xs" (click)="startEdit()" [disabled]="mode() !== ''" data-testid="file-edit">Edit</button>}
1958
+ <button type="button" class="o-btn o-xs" (click)="mode.set('delete')" [disabled]="mode() !== ''" data-testid="file-delete">Delete</button>
1959
+ }
1727
1960
  </span>
1728
1961
  </div>
1962
+ @if (mode() === 'edit') {
1963
+ <div class="o-cb o-col" style="gap:10px;background:var(--o-well);border-bottom:1px solid var(--o-line-soft)">
1964
+ @if (text() === null) {<span class="o-faint o-sm">Loading the file…</span>} @else {
1965
+ <textarea class="o-in o-mono" rows="16" style="font-size:12px;height:auto;padding:8px 10px" aria-label="File contents" [ngModel]="text()" (ngModelChange)="text.set($event)" data-testid="file-text"></textarea>
1966
+ <o-commit-box [repo]="r.name" [branch]="branch()" [defaultMessage]="'Update ' + b.name" [busy]="busy()" (commit)="save($event)" (cancel)="mode.set('')" />
1967
+ }
1968
+ </div>
1969
+ }
1970
+ @if (mode() === 'delete') {
1971
+ <div class="o-cb o-col" style="gap:10px;background:var(--o-well);border-bottom:1px solid var(--o-line-soft)">
1972
+ <span class="o-sm">Delete <b class="o-mono">{{ b.path }}</b> from <b class="o-mono">{{ branch() }}</b>?</span>
1973
+ <o-commit-box [repo]="r.name" [branch]="branch()" [defaultMessage]="'Delete ' + b.name" [busy]="busy()" (commit)="save($event, true)" (cancel)="mode.set('')" />
1974
+ </div>
1975
+ }
1729
1976
  @if (b.image && b.rawUrl) {
1730
1977
  <div class="o-cb" style="text-align:center"><img [src]="b.rawUrl" [alt]="b.name" style="max-width:100%" /></div>
1731
1978
  } @else if (b.binary || b.truncated) {
@@ -3000,9 +3247,46 @@ class RepoSettingsPage extends RepoPage {
3000
3247
  { key: 'dismissStaleApprovals', label: 'Dismiss stale approvals', hint: 'A new commit dismisses approvals given on an older one.' },
3001
3248
  { key: 'requireUpToDate', label: 'Require branches to be up to date', hint: 'The branch must contain the target’s latest commit before it merges.' },
3002
3249
  ];
3250
+ this.collabs = signal([], ...(ngDevMode ? [{ debugName: "collabs" }] : /* istanbul ignore next */ []));
3251
+ this.perms = [{ id: 'read', label: 'Read' }, { id: 'write', label: 'Write' }, { id: 'maintain', label: 'Maintain' }];
3252
+ this.addable = computed(() => this.host.people().filter((p) => !this.collabs().some((c) => c.userId === p.id)), ...(ngDevMode ? [{ debugName: "addable" }] : /* istanbul ignore next */ []));
3253
+ this.newCollab = '';
3254
+ this.newPerm = 'write';
3003
3255
  this.route.queryParamMap.subscribe((q) => this.section.set(q.get('section') || 'general'));
3004
3256
  }
3257
+ roleLabel(r) {
3258
+ return r ? r[0].toUpperCase() + r.slice(1) : 'Not in the project';
3259
+ }
3260
+ loadCollabs(r) {
3261
+ if (!this.canAdmin())
3262
+ return;
3263
+ this.api.collaborators(this.projectId(), r.name).subscribe({ next: (l) => this.collabs.set(l), error: () => undefined });
3264
+ }
3265
+ setCollab(userId, permission, adding = false) {
3266
+ const r = this.repo();
3267
+ if (!r || !userId)
3268
+ return;
3269
+ this.busy.set(true);
3270
+ this.error.set('');
3271
+ this.api.setCollaborator(this.projectId(), r.name, userId, permission).subscribe({
3272
+ next: () => { this.busy.set(false); if (adding) {
3273
+ this.newCollab = '';
3274
+ this.note.set(`${this.people().name(userId)} now has ${permission} on ${r.name}.`);
3275
+ } this.loadCollabs(r); },
3276
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not change the access.')); this.loadCollabs(r); },
3277
+ });
3278
+ }
3279
+ removeCollab(userId) {
3280
+ const r = this.repo();
3281
+ if (!r)
3282
+ return;
3283
+ this.api.removeCollaborator(this.projectId(), r.name, userId).subscribe({
3284
+ next: () => { this.note.set(`${this.people().name(userId)} is no longer a collaborator.`); this.loadCollabs(r); },
3285
+ error: (e) => this.error.set(apiMessage(e, 'Could not remove the collaborator.')),
3286
+ });
3287
+ }
3005
3288
  onRepo(r) {
3289
+ this.loadCollabs(r);
3006
3290
  this.g = { name: r.name, description: r.description, visibility: r.visibility, defaultBranch: r.defaultBranch, mergeMethods: [...(r.mergeMethods ?? [])], deleteBranchOnMerge: r.deleteBranchOnMerge };
3007
3291
  if (!r.empty)
3008
3292
  this.api.refs(this.projectId(), r.name, 'branch').subscribe({ next: (b) => this.branches.set(b), error: () => undefined });
@@ -3055,7 +3339,11 @@ class RepoSettingsPage extends RepoPage {
3055
3339
  return;
3056
3340
  this.busy.set(true);
3057
3341
  const renamed = this.g.name.trim() !== r.name;
3058
- this.api.update(this.projectId(), r.name, { name: this.g.name.trim(), description: this.g.description, visibility: this.g.visibility, defaultBranch: this.g.defaultBranch, mergeMethods: this.g.mergeMethods, deleteBranchOnMerge: this.g.deleteBranchOnMerge }).subscribe({
3342
+ // a maintain collaborator sends what they may change; the name and visibility stay a Maintainer's
3343
+ const body = this.canAdmin()
3344
+ ? { name: this.g.name.trim(), description: this.g.description, visibility: this.g.visibility, defaultBranch: this.g.defaultBranch, mergeMethods: this.g.mergeMethods, deleteBranchOnMerge: this.g.deleteBranchOnMerge }
3345
+ : { description: this.g.description, defaultBranch: this.g.defaultBranch, mergeMethods: this.g.mergeMethods, deleteBranchOnMerge: this.g.deleteBranchOnMerge };
3346
+ this.api.update(this.projectId(), r.name, body).subscribe({
3059
3347
  next: (n) => {
3060
3348
  this.busy.set(false);
3061
3349
  this.note.set('Saved.');
@@ -3120,7 +3408,7 @@ class RepoSettingsPage extends RepoPage {
3120
3408
  <span class="o-lbl" style="padding-top:0">{{ r.name }}</span>
3121
3409
  <button type="button" [class.o-on]="section() === 'general'" (click)="go('general')">General</button>
3122
3410
  <button type="button" [class.o-on]="section() === 'protect'" (click)="go('protect')">Branch protection</button>
3123
- <button type="button" [class.o-on]="section() === 'collab'" (click)="go('collab')">Collaborators<b class="o-ph-tag o-ga o-r">GA</b></button>
3411
+ <button type="button" [class.o-on]="section() === 'collab'" (click)="go('collab')">Collaborators</button>
3124
3412
  <button type="button" [class.o-on]="section() === 'danger'" (click)="go('danger')">Danger zone</button>
3125
3413
  </nav>
3126
3414
  <div class="o-col" style="gap:16px;min-width:0">
@@ -3132,9 +3420,9 @@ class RepoSettingsPage extends RepoPage {
3132
3420
  <form class="o-card o-cb o-form" (ngSubmit)="saveGeneral()">
3133
3421
  <div class="o-g2">
3134
3422
  <div class="o-fld"><label for="rs-name">Repository name</label><input id="rs-name" class="o-in o-mono" name="name" [(ngModel)]="g.name" [disabled]="!canAdmin()" /><span class="o-hint">Renaming keeps the old clone URL working as a redirect.</span></div>
3135
- <div class="o-fld"><label for="rs-def">Default branch</label><select id="rs-def" class="o-in" name="def" [(ngModel)]="g.defaultBranch" [disabled]="!canAdmin()">@for (b of branches(); track b.name) {<option [value]="b.name">{{ b.name }}</option>}@if (!branches().length) {<option [value]="r.defaultBranch">{{ r.defaultBranch }}</option>}</select></div>
3423
+ <div class="o-fld"><label for="rs-def">Default branch</label><select id="rs-def" class="o-in" name="def" [(ngModel)]="g.defaultBranch" [disabled]="!canMaintain()">@for (b of branches(); track b.name) {<option [value]="b.name">{{ b.name }}</option>}@if (!branches().length) {<option [value]="r.defaultBranch">{{ r.defaultBranch }}</option>}</select></div>
3136
3424
  </div>
3137
- <div class="o-fld"><label for="rs-desc">Description</label><input id="rs-desc" class="o-in" name="desc" [(ngModel)]="g.description" [disabled]="!canAdmin()" /></div>
3425
+ <div class="o-fld"><label for="rs-desc">Description</label><input id="rs-desc" class="o-in" name="desc" [(ngModel)]="g.description" [disabled]="!canMaintain()" /></div>
3138
3426
  <div class="o-fld"><span class="o-lbl" style="padding:0">Visibility</span>
3139
3427
  <div class="o-g2">
3140
3428
  <button type="button" class="o-opt" [class.o-on]="g.visibility === 'private'" (click)="g.visibility = 'private'" [disabled]="!canAdmin()"><span class="o-rad" [class.o-on]="g.visibility === 'private'"></span><span class="o-col" style="gap:2px"><b>Private</b><span class="o-faint o-sm">Project members only</span></span></button>
@@ -3142,11 +3430,11 @@ class RepoSettingsPage extends RepoPage {
3142
3430
  </div>
3143
3431
  </div>
3144
3432
  <div class="o-fld"><span class="o-lbl" style="padding:0">Allowed merge methods</span>
3145
- @for (m of methods; track m.id) {<label class="o-flex"><input type="checkbox" [checked]="g.mergeMethods.includes(m.id)" (change)="flipMethod(m.id)" [disabled]="!canAdmin()" />{{ m.label }}</label>}
3433
+ @for (m of methods; track m.id) {<label class="o-flex"><input type="checkbox" [checked]="g.mergeMethods.includes(m.id)" (change)="flipMethod(m.id)" [disabled]="!canMaintain()" />{{ m.label }}</label>}
3146
3434
  </div>
3147
- <div class="o-flex"><button type="button" class="o-tog" role="switch" aria-labelledby="rs-del" [class.o-on]="g.deleteBranchOnMerge" [attr.aria-checked]="g.deleteBranchOnMerge" (click)="g.deleteBranchOnMerge = !g.deleteBranchOnMerge" [disabled]="!canAdmin()"></button><span id="rs-del">Delete head branches after merge</span></div>
3435
+ <div class="o-flex"><button type="button" class="o-tog" role="switch" aria-labelledby="rs-del" [class.o-on]="g.deleteBranchOnMerge" [attr.aria-checked]="g.deleteBranchOnMerge" (click)="g.deleteBranchOnMerge = !g.deleteBranchOnMerge" [disabled]="!canMaintain()"></button><span id="rs-del">Delete head branches after merge</span></div>
3148
3436
  <div class="o-flex"><button type="button" class="o-tog o-dis" disabled aria-label="Allow auto-merge"></button>Allow auto-merge when all requirements pass<b class="o-ph-tag o-ga">GA</b></div>
3149
- @if (canAdmin()) {<div class="o-flex"><button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !g.mergeMethods.length">Save</button></div>}
3437
+ @if (canMaintain()) {<div class="o-flex">@if (!canAdmin()) {<span class="o-faint o-sm">As a maintain collaborator you change these settings; a project Maintainer renames the repository or changes its visibility.</span>}<button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !g.mergeMethods.length" data-testid="settings-save">Save</button></div>}
3150
3438
  </form>
3151
3439
  }
3152
3440
  @case ('protect') {
@@ -3183,7 +3471,34 @@ class RepoSettingsPage extends RepoPage {
3183
3471
  }
3184
3472
  @case ('collab') {
3185
3473
  <h1 class="o-t">Collaborators</h1>
3186
- <div class="o-card"><div class="o-empty">Access beyond the project role, for one repository, arrives at general availability. Until then, project roles decide who reads and pushes.</div></div>
3474
+ <span class="o-mut">Access beyond the project role, for this repository only.</span>
3475
+ @if (canAdmin()) {
3476
+ <div class="o-card" style="overflow-x:auto" data-testid="collaborators">
3477
+ <div class="o-tbl" role="table" aria-label="Collaborators" style="min-width:560px">
3478
+ <div class="o-thead" role="rowgroup"><div class="o-tr" role="row"><div class="o-th" role="columnheader">Person</div><div class="o-th" role="columnheader">Project role</div><div class="o-th" role="columnheader">Repository access</div><div class="o-th" role="columnheader"></div></div></div>
3479
+ <div class="o-tbody" role="rowgroup">
3480
+ @for (cl of collabs(); track cl.userId) {
3481
+ <div class="o-tr" role="row" [attr.data-testid]="'collab-' + cl.userId">
3482
+ <div class="o-td" role="cell"><span class="o-flex"><span class="o-av" aria-hidden="true">{{ people().initials(cl.userId) }}</span>{{ people().name(cl.userId) }}@if (cl.projectRole === 'guest') {<span class="o-chip">Guest</span>}</span></div>
3483
+ <div class="o-td o-mut" role="cell">{{ roleLabel(cl.projectRole) }}</div>
3484
+ <div class="o-td" role="cell"><select class="o-in o-sm" style="width:auto" [attr.aria-label]="'Repository access for ' + people().name(cl.userId)" [ngModel]="cl.permission" (ngModelChange)="setCollab(cl.userId, $event)">@for (p of perms; track p.id) {<option [value]="p.id">{{ p.label }}</option>}</select></div>
3485
+ <div class="o-td o-num" role="cell"><button type="button" class="o-btn o-xs o-ghost" (click)="removeCollab(cl.userId)" [attr.aria-label]="'Remove ' + people().name(cl.userId)">Remove</button></div>
3486
+ </div>
3487
+ } @empty {
3488
+ <div class="o-tr" role="row"><div class="o-td o-faint" role="cell" style="grid-column:1 / -1">No collaborators. Project roles decide who reads and pushes.</div></div>
3489
+ }
3490
+ </div>
3491
+ </div>
3492
+ <div class="o-cf">
3493
+ <select class="o-in o-sm" style="width:auto" aria-label="Person to add" [(ngModel)]="newCollab"><option value="">Add a person…</option>@for (p of addable(); track p.id) {<option [value]="p.id">{{ p.name || p.email }}</option>}</select>
3494
+ <select class="o-in o-sm" style="width:auto" aria-label="Access to give" [(ngModel)]="newPerm">@for (p of perms; track p.id) {<option [value]="p.id">{{ p.label }}</option>}</select>
3495
+ <button type="button" class="o-btn o-sm o-dark" [disabled]="!newCollab || busy()" (click)="setCollab(newCollab, newPerm, true)" data-testid="collab-add">Add</button>
3496
+ <span class="o-faint o-xs">They need to be in the project (any role, Guest included). Write pushes and opens pull requests here; maintain also changes this repository's settings.</span>
3497
+ </div>
3498
+ </div>
3499
+ } @else {
3500
+ <div class="o-card"><div class="o-empty">{{ repo()?.collaborator ? 'You have ' + repo()?.collaborator + ' on this repository as a collaborator. ' : '' }}Project Maintainers manage collaborators.</div></div>
3501
+ }
3187
3502
  }
3188
3503
  @case ('danger') {
3189
3504
  <h1 class="o-t">Danger zone</h1>
@@ -3218,7 +3533,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3218
3533
  <span class="o-lbl" style="padding-top:0">{{ r.name }}</span>
3219
3534
  <button type="button" [class.o-on]="section() === 'general'" (click)="go('general')">General</button>
3220
3535
  <button type="button" [class.o-on]="section() === 'protect'" (click)="go('protect')">Branch protection</button>
3221
- <button type="button" [class.o-on]="section() === 'collab'" (click)="go('collab')">Collaborators<b class="o-ph-tag o-ga o-r">GA</b></button>
3536
+ <button type="button" [class.o-on]="section() === 'collab'" (click)="go('collab')">Collaborators</button>
3222
3537
  <button type="button" [class.o-on]="section() === 'danger'" (click)="go('danger')">Danger zone</button>
3223
3538
  </nav>
3224
3539
  <div class="o-col" style="gap:16px;min-width:0">
@@ -3230,9 +3545,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3230
3545
  <form class="o-card o-cb o-form" (ngSubmit)="saveGeneral()">
3231
3546
  <div class="o-g2">
3232
3547
  <div class="o-fld"><label for="rs-name">Repository name</label><input id="rs-name" class="o-in o-mono" name="name" [(ngModel)]="g.name" [disabled]="!canAdmin()" /><span class="o-hint">Renaming keeps the old clone URL working as a redirect.</span></div>
3233
- <div class="o-fld"><label for="rs-def">Default branch</label><select id="rs-def" class="o-in" name="def" [(ngModel)]="g.defaultBranch" [disabled]="!canAdmin()">@for (b of branches(); track b.name) {<option [value]="b.name">{{ b.name }}</option>}@if (!branches().length) {<option [value]="r.defaultBranch">{{ r.defaultBranch }}</option>}</select></div>
3548
+ <div class="o-fld"><label for="rs-def">Default branch</label><select id="rs-def" class="o-in" name="def" [(ngModel)]="g.defaultBranch" [disabled]="!canMaintain()">@for (b of branches(); track b.name) {<option [value]="b.name">{{ b.name }}</option>}@if (!branches().length) {<option [value]="r.defaultBranch">{{ r.defaultBranch }}</option>}</select></div>
3234
3549
  </div>
3235
- <div class="o-fld"><label for="rs-desc">Description</label><input id="rs-desc" class="o-in" name="desc" [(ngModel)]="g.description" [disabled]="!canAdmin()" /></div>
3550
+ <div class="o-fld"><label for="rs-desc">Description</label><input id="rs-desc" class="o-in" name="desc" [(ngModel)]="g.description" [disabled]="!canMaintain()" /></div>
3236
3551
  <div class="o-fld"><span class="o-lbl" style="padding:0">Visibility</span>
3237
3552
  <div class="o-g2">
3238
3553
  <button type="button" class="o-opt" [class.o-on]="g.visibility === 'private'" (click)="g.visibility = 'private'" [disabled]="!canAdmin()"><span class="o-rad" [class.o-on]="g.visibility === 'private'"></span><span class="o-col" style="gap:2px"><b>Private</b><span class="o-faint o-sm">Project members only</span></span></button>
@@ -3240,11 +3555,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3240
3555
  </div>
3241
3556
  </div>
3242
3557
  <div class="o-fld"><span class="o-lbl" style="padding:0">Allowed merge methods</span>
3243
- @for (m of methods; track m.id) {<label class="o-flex"><input type="checkbox" [checked]="g.mergeMethods.includes(m.id)" (change)="flipMethod(m.id)" [disabled]="!canAdmin()" />{{ m.label }}</label>}
3558
+ @for (m of methods; track m.id) {<label class="o-flex"><input type="checkbox" [checked]="g.mergeMethods.includes(m.id)" (change)="flipMethod(m.id)" [disabled]="!canMaintain()" />{{ m.label }}</label>}
3244
3559
  </div>
3245
- <div class="o-flex"><button type="button" class="o-tog" role="switch" aria-labelledby="rs-del" [class.o-on]="g.deleteBranchOnMerge" [attr.aria-checked]="g.deleteBranchOnMerge" (click)="g.deleteBranchOnMerge = !g.deleteBranchOnMerge" [disabled]="!canAdmin()"></button><span id="rs-del">Delete head branches after merge</span></div>
3560
+ <div class="o-flex"><button type="button" class="o-tog" role="switch" aria-labelledby="rs-del" [class.o-on]="g.deleteBranchOnMerge" [attr.aria-checked]="g.deleteBranchOnMerge" (click)="g.deleteBranchOnMerge = !g.deleteBranchOnMerge" [disabled]="!canMaintain()"></button><span id="rs-del">Delete head branches after merge</span></div>
3246
3561
  <div class="o-flex"><button type="button" class="o-tog o-dis" disabled aria-label="Allow auto-merge"></button>Allow auto-merge when all requirements pass<b class="o-ph-tag o-ga">GA</b></div>
3247
- @if (canAdmin()) {<div class="o-flex"><button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !g.mergeMethods.length">Save</button></div>}
3562
+ @if (canMaintain()) {<div class="o-flex">@if (!canAdmin()) {<span class="o-faint o-sm">As a maintain collaborator you change these settings; a project Maintainer renames the repository or changes its visibility.</span>}<button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !g.mergeMethods.length" data-testid="settings-save">Save</button></div>}
3248
3563
  </form>
3249
3564
  }
3250
3565
  @case ('protect') {
@@ -3281,7 +3596,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3281
3596
  }
3282
3597
  @case ('collab') {
3283
3598
  <h1 class="o-t">Collaborators</h1>
3284
- <div class="o-card"><div class="o-empty">Access beyond the project role, for one repository, arrives at general availability. Until then, project roles decide who reads and pushes.</div></div>
3599
+ <span class="o-mut">Access beyond the project role, for this repository only.</span>
3600
+ @if (canAdmin()) {
3601
+ <div class="o-card" style="overflow-x:auto" data-testid="collaborators">
3602
+ <div class="o-tbl" role="table" aria-label="Collaborators" style="min-width:560px">
3603
+ <div class="o-thead" role="rowgroup"><div class="o-tr" role="row"><div class="o-th" role="columnheader">Person</div><div class="o-th" role="columnheader">Project role</div><div class="o-th" role="columnheader">Repository access</div><div class="o-th" role="columnheader"></div></div></div>
3604
+ <div class="o-tbody" role="rowgroup">
3605
+ @for (cl of collabs(); track cl.userId) {
3606
+ <div class="o-tr" role="row" [attr.data-testid]="'collab-' + cl.userId">
3607
+ <div class="o-td" role="cell"><span class="o-flex"><span class="o-av" aria-hidden="true">{{ people().initials(cl.userId) }}</span>{{ people().name(cl.userId) }}@if (cl.projectRole === 'guest') {<span class="o-chip">Guest</span>}</span></div>
3608
+ <div class="o-td o-mut" role="cell">{{ roleLabel(cl.projectRole) }}</div>
3609
+ <div class="o-td" role="cell"><select class="o-in o-sm" style="width:auto" [attr.aria-label]="'Repository access for ' + people().name(cl.userId)" [ngModel]="cl.permission" (ngModelChange)="setCollab(cl.userId, $event)">@for (p of perms; track p.id) {<option [value]="p.id">{{ p.label }}</option>}</select></div>
3610
+ <div class="o-td o-num" role="cell"><button type="button" class="o-btn o-xs o-ghost" (click)="removeCollab(cl.userId)" [attr.aria-label]="'Remove ' + people().name(cl.userId)">Remove</button></div>
3611
+ </div>
3612
+ } @empty {
3613
+ <div class="o-tr" role="row"><div class="o-td o-faint" role="cell" style="grid-column:1 / -1">No collaborators. Project roles decide who reads and pushes.</div></div>
3614
+ }
3615
+ </div>
3616
+ </div>
3617
+ <div class="o-cf">
3618
+ <select class="o-in o-sm" style="width:auto" aria-label="Person to add" [(ngModel)]="newCollab"><option value="">Add a person…</option>@for (p of addable(); track p.id) {<option [value]="p.id">{{ p.name || p.email }}</option>}</select>
3619
+ <select class="o-in o-sm" style="width:auto" aria-label="Access to give" [(ngModel)]="newPerm">@for (p of perms; track p.id) {<option [value]="p.id">{{ p.label }}</option>}</select>
3620
+ <button type="button" class="o-btn o-sm o-dark" [disabled]="!newCollab || busy()" (click)="setCollab(newCollab, newPerm, true)" data-testid="collab-add">Add</button>
3621
+ <span class="o-faint o-xs">They need to be in the project (any role, Guest included). Write pushes and opens pull requests here; maintain also changes this repository's settings.</span>
3622
+ </div>
3623
+ </div>
3624
+ } @else {
3625
+ <div class="o-card"><div class="o-empty">{{ repo()?.collaborator ? 'You have ' + repo()?.collaborator + ' on this repository as a collaborator. ' : '' }}Project Maintainers manage collaborators.</div></div>
3626
+ }
3285
3627
  }
3286
3628
  @case ('danger') {
3287
3629
  <h1 class="o-t">Danger zone</h1>
@@ -3684,6 +4026,169 @@ function sortTags(tags) {
3684
4026
  return [...tags].sort((a, b) => when(b) - when(a) || b.name.localeCompare(a.name, undefined, { numeric: true }));
3685
4027
  }
3686
4028
 
4029
+ /** Uploads in one commit stay under the server's 25 MB request limit (base64 grows them by a third). */
4030
+ const MAX_UPLOAD = 18 * 1024 * 1024;
4031
+ // /{org}/{project}/code/{repo}/new?ref=&path= and /upload (DEV-13): a new file written in the
4032
+ // browser, or files uploaded from the computer, committed to the branch or to a new branch with a
4033
+ // pull request (protection applies).
4034
+ class NewFilePage extends RepoPage {
4035
+ constructor() {
4036
+ super();
4037
+ this.router = inject(Router);
4038
+ this.upload = signal(false, ...(ngDevMode ? [{ debugName: "upload" }] : /* istanbul ignore next */ []));
4039
+ this.ref = signal('', ...(ngDevMode ? [{ debugName: "ref" }] : /* istanbul ignore next */ []));
4040
+ this.uploads = signal([], ...(ngDevMode ? [{ debugName: "uploads" }] : /* istanbul ignore next */ []));
4041
+ this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
4042
+ this.filePath = '';
4043
+ this.content = '';
4044
+ this.dir = '';
4045
+ this.branch = computed(() => this.ref() || this.repo()?.defaultBranch || 'main', ...(ngDevMode ? [{ debugName: "branch" }] : /* istanbul ignore next */ []));
4046
+ this.defaultMessage = computed(() => (this.upload() ? 'Add files via upload' : 'Create a file'), ...(ngDevMode ? [{ debugName: "defaultMessage" }] : /* istanbul ignore next */ []));
4047
+ this.fmt = size;
4048
+ this.upload.set(this.route.snapshot.url.at(-1)?.path === 'upload');
4049
+ this.route.queryParamMap.subscribe((q) => {
4050
+ this.ref.set(q.get('ref') ?? '');
4051
+ const p = q.get('path') ?? '';
4052
+ this.dir = p;
4053
+ this.filePath = p ? p.replace(/\/+$/, '') + '/' : '';
4054
+ });
4055
+ }
4056
+ onRepo(_) { }
4057
+ async pick(ev) {
4058
+ const files = Array.from(ev.target.files ?? []);
4059
+ const out = [...this.uploads()];
4060
+ for (const f of files) {
4061
+ const buf = new Uint8Array(await f.arrayBuffer());
4062
+ let bin = '';
4063
+ for (let i = 0; i < buf.length; i += 0x8000)
4064
+ bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
4065
+ out.push({ name: f.name, size: f.size, b64: btoa(bin) });
4066
+ }
4067
+ if (out.reduce((a, u) => a + u.size, 0) > MAX_UPLOAD) {
4068
+ this.error.set('One upload is at most 18 MB; commit large files with Git.');
4069
+ return;
4070
+ }
4071
+ this.error.set('');
4072
+ this.uploads.set(out);
4073
+ }
4074
+ shown(name) {
4075
+ const prefix = this.dir.trim().replace(/^\/+|\/+$/g, '');
4076
+ return (prefix ? prefix + '/' : '') + name;
4077
+ }
4078
+ drop(name) {
4079
+ this.uploads.set(this.uploads().filter((u) => u.name !== name));
4080
+ }
4081
+ back() {
4082
+ void this.router.navigate(this.link(), { queryParams: { ref: this.ref() || null, path: this.dir || null } });
4083
+ }
4084
+ save(c) {
4085
+ const r = this.repo();
4086
+ if (!r)
4087
+ return;
4088
+ const body = c.newBranch ? { branch: c.newBranch, from: this.branch(), message: c.message, pull: {} } : { branch: this.branch(), message: c.message };
4089
+ if (this.upload()) {
4090
+ if (!this.uploads().length) {
4091
+ this.error.set('Choose files to upload.');
4092
+ return;
4093
+ }
4094
+ const prefix = this.dir.trim().replace(/^\/+|\/+$/g, '');
4095
+ body.uploads = Object.fromEntries(this.uploads().map((u) => [(prefix ? prefix + '/' : '') + u.name, u.b64]));
4096
+ }
4097
+ else {
4098
+ const p = this.filePath.trim().replace(/^\/+/, '');
4099
+ if (!p || p.endsWith('/')) {
4100
+ this.error.set('Name the file.');
4101
+ return;
4102
+ }
4103
+ body.files = { [p]: this.content };
4104
+ }
4105
+ this.busy.set(true);
4106
+ this.error.set('');
4107
+ this.api.commitFiles(this.projectId(), r.name, body).subscribe({
4108
+ next: (res) => {
4109
+ this.busy.set(false);
4110
+ if (res.pull) {
4111
+ void this.router.navigate(this.link('pull/' + res.pull));
4112
+ return;
4113
+ }
4114
+ if (!this.upload()) {
4115
+ void this.router.navigate(this.link('blob'), { queryParams: { ref: res.branch, path: this.filePath.trim().replace(/^\/+/, '') } });
4116
+ return;
4117
+ }
4118
+ void this.router.navigate(this.link(), { queryParams: { ref: res.branch, path: this.dir.trim() || null } });
4119
+ },
4120
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not commit.')); },
4121
+ });
4122
+ }
4123
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: NewFilePage, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4124
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: NewFilePage, isStandalone: true, selector: "o-code-new-file", usesInheritance: true, ngImport: i0, template: `
4125
+ <div class="o-page o-wide">
4126
+ @if (repo(); as r) {
4127
+ <div class="o-ph"><div class="o-col"><span class="o-eyebrow">{{ r.name }} · {{ branch() }}</span><h1 class="o-t">{{ upload() ? 'Upload files' : 'Create a file' }}</h1></div></div>
4128
+ @if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
4129
+ @if (!canWrite()) {
4130
+ <div class="o-card"><div class="o-empty">Your role cannot change this repository.</div></div>
4131
+ } @else {
4132
+ <div class="o-card">
4133
+ <div class="o-cb o-col" style="gap:12px">
4134
+ @if (upload()) {
4135
+ <div class="o-fld"><label for="nf-dir">Into folder</label><input id="nf-dir" class="o-in o-mono" [(ngModel)]="dir" placeholder="(the repository root)" /></div>
4136
+ <div class="o-fld"><label for="nf-files">Files</label><input id="nf-files" type="file" multiple class="o-in" (change)="pick($event)" data-testid="upload-files" /></div>
4137
+ @for (u of uploads(); track u.name) {<div class="o-row o-sm"><span class="o-mono">{{ shown(u.name) }}</span><span class="o-r o-faint">{{ fmt(u.size) }}</span><button type="button" class="o-lnk" (click)="drop(u.name)" [attr.aria-label]="'Remove ' + u.name">×</button></div>}
4138
+ } @else {
4139
+ <div class="o-fld"><label for="nf-path">File path</label><input id="nf-path" class="o-in o-mono" [(ngModel)]="filePath" placeholder="docs/notes.md" data-testid="new-file-path" /></div>
4140
+ <textarea class="o-in o-mono" rows="16" style="font-size:12px;height:auto;padding:8px 10px" aria-label="File contents" [(ngModel)]="content" data-testid="new-file-text"></textarea>
4141
+ }
4142
+ </div>
4143
+ <div class="o-cb" style="border-top:1px solid var(--o-line-soft)">
4144
+ <o-commit-box [repo]="r.name" [branch]="branch()" [defaultMessage]="defaultMessage()" [busy]="busy()" (commit)="save($event)" (cancel)="back()" />
4145
+ </div>
4146
+ </div>
4147
+ }
4148
+ } @else if (error()) {
4149
+ <div class="o-card o-empty"><p>{{ error() }}</p><a class="o-btn" [routerLink]="host.link('code')">All repositories</a></div>
4150
+ }
4151
+ </div>
4152
+ `, isInline: true, 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: CommitBox, selector: "o-commit-box", inputs: ["repo", "branch", "defaultMessage", "busy"], outputs: ["commit", "cancel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
4153
+ }
4154
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: NewFilePage, decorators: [{
4155
+ type: Component,
4156
+ args: [{
4157
+ selector: 'o-code-new-file',
4158
+ imports: [FormsModule, RouterLink, CommitBox],
4159
+ changeDetection: ChangeDetectionStrategy.OnPush,
4160
+ template: `
4161
+ <div class="o-page o-wide">
4162
+ @if (repo(); as r) {
4163
+ <div class="o-ph"><div class="o-col"><span class="o-eyebrow">{{ r.name }} · {{ branch() }}</span><h1 class="o-t">{{ upload() ? 'Upload files' : 'Create a file' }}</h1></div></div>
4164
+ @if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
4165
+ @if (!canWrite()) {
4166
+ <div class="o-card"><div class="o-empty">Your role cannot change this repository.</div></div>
4167
+ } @else {
4168
+ <div class="o-card">
4169
+ <div class="o-cb o-col" style="gap:12px">
4170
+ @if (upload()) {
4171
+ <div class="o-fld"><label for="nf-dir">Into folder</label><input id="nf-dir" class="o-in o-mono" [(ngModel)]="dir" placeholder="(the repository root)" /></div>
4172
+ <div class="o-fld"><label for="nf-files">Files</label><input id="nf-files" type="file" multiple class="o-in" (change)="pick($event)" data-testid="upload-files" /></div>
4173
+ @for (u of uploads(); track u.name) {<div class="o-row o-sm"><span class="o-mono">{{ shown(u.name) }}</span><span class="o-r o-faint">{{ fmt(u.size) }}</span><button type="button" class="o-lnk" (click)="drop(u.name)" [attr.aria-label]="'Remove ' + u.name">×</button></div>}
4174
+ } @else {
4175
+ <div class="o-fld"><label for="nf-path">File path</label><input id="nf-path" class="o-in o-mono" [(ngModel)]="filePath" placeholder="docs/notes.md" data-testid="new-file-path" /></div>
4176
+ <textarea class="o-in o-mono" rows="16" style="font-size:12px;height:auto;padding:8px 10px" aria-label="File contents" [(ngModel)]="content" data-testid="new-file-text"></textarea>
4177
+ }
4178
+ </div>
4179
+ <div class="o-cb" style="border-top:1px solid var(--o-line-soft)">
4180
+ <o-commit-box [repo]="r.name" [branch]="branch()" [defaultMessage]="defaultMessage()" [busy]="busy()" (commit)="save($event)" (cancel)="back()" />
4181
+ </div>
4182
+ </div>
4183
+ }
4184
+ } @else if (error()) {
4185
+ <div class="o-card o-empty"><p>{{ error() }}</p><a class="o-btn" [routerLink]="host.link('code')">All repositories</a></div>
4186
+ }
4187
+ </div>
4188
+ `,
4189
+ }]
4190
+ }], ctorParameters: () => [] });
4191
+
3687
4192
  /*
3688
4193
  * @chisquare-tech/origine-ngx-apps-code: the Code module's screens (TRD §6.10). The host app provides
3689
4194
  * CodeHost and mounts the pages at /{org}/{project}/code, /code/new, /code/pulls and
@@ -3694,5 +4199,5 @@ function sortTags(tags) {
3694
4199
  * Generated bundle index. Do not edit.
3695
4200
  */
3696
4201
 
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 };
4202
+ export { BranchesPage, CodeApi, CodeHost, CommitBox, CommitPage, CommitsPage, CurrentRepo, CurrentRepoPage, DiffView, FilePage, NewFilePage, NewRepoPage, People, PullPage, PullsPage, RefPicker, ReleasesPage, RepoHomePage, RepoPage, RepoSettingsPage, ReposPage, TOKEN_CSS, ago, apiMessage, checkDot, copy, dayLabel, initials, langColour, matchesPattern, pair, shortDate, size };
3698
4203
  //# sourceMappingURL=chisquare-tech-origine-ngx-apps-code.mjs.map