Merge remote-tracking branch 'origin/main' into claude/cool-newton-7nm8r9
# Conflicts: # apps/api/src/operations.rs
57 files+1159−5041 files not counted0/57 viewed
| 125 | 125 | Op::UpdateRepo, | |
| 126 | 126 | Op::RenameRepo, | |
| 127 | 127 | Op::RenameBranch, | |
| 128 | + | Op::CreateBranch, | |
| 129 | + | Op::DeleteBranch, | |
| 128 | 130 | Op::SetRepoVisibility, | |
| 129 | 131 | Op::ArchiveRepo, | |
| 130 | 132 | Op::UnarchiveRepo, | |
| ⋯ | |||
| 661 | 663 | Op::TransferRepo => "Transfer a repository", | |
| 662 | 664 | Op::RenameRepo => "Rename a repository", | |
| 663 | 665 | Op::RenameBranch => "Rename a branch", | |
| 666 | + | Op::CreateBranch => "Create a branch", | |
| 667 | + | Op::DeleteBranch => "Delete a branch", | |
| 664 | 668 | Op::ArchiveRepo => "Archive a repository", | |
| 665 | 669 | Op::UnarchiveRepo => "Unarchive a repository", | |
| 666 | 670 | Op::SetRepoVisibility => "Change a repository's visibility", | |
| 144 | 144 | TransferRepo, | |
| 145 | 145 | RenameRepo, | |
| 146 | 146 | RenameBranch, | |
| 147 | + | CreateBranch, | |
| 148 | + | DeleteBranch, | |
| 147 | 149 | ArchiveRepo, | |
| 148 | 150 | UnarchiveRepo, | |
| 149 | 151 | SetRepoVisibility, | |
| ⋯ | |||
| 714 | 716 | } | |
| 715 | 717 | ||
| 716 | 718 | impl Op { | |
| 717 | − | pub const ALL: [Op; 386] = [ | |
| 719 | + | pub const ALL: [Op; 388] = [ | |
| 718 | 720 | Op::Whoami, | |
| 719 | 721 | Op::GetWorkspace, | |
| 720 | 722 | Op::CreateWorkspace, | |
| ⋯ | |||
| 746 | 748 | Op::TransferRepo, | |
| 747 | 749 | Op::RenameRepo, | |
| 748 | 750 | Op::RenameBranch, | |
| 751 | + | Op::CreateBranch, | |
| 752 | + | Op::DeleteBranch, | |
| 749 | 753 | Op::ArchiveRepo, | |
| 750 | 754 | Op::UnarchiveRepo, | |
| 751 | 755 | Op::SetRepoVisibility, | |
| ⋯ | |||
| 1141 | 1145 | Op::TransferRepo => "transfer_repo", | |
| 1142 | 1146 | Op::RenameRepo => "rename_repo", | |
| 1143 | 1147 | Op::RenameBranch => "rename_branch", | |
| 1148 | + | Op::CreateBranch => "create_branch", | |
| 1149 | + | Op::DeleteBranch => "delete_branch", | |
| 1144 | 1150 | Op::ArchiveRepo => "archive_repo", | |
| 1145 | 1151 | Op::UnarchiveRepo => "unarchive_repo", | |
| 1146 | 1152 | Op::SetRepoVisibility => "set_repo_visibility", | |
| ⋯ | |||
| 1407 | 1413 | Op::RenameBranch => { | |
| 1408 | 1414 | "Rename a branch. Needs the Write role or higher; the default branch, which stays the default, needs the Admin role. Open pull requests from the branch follow it, and web addresses that name the old branch redirect until a branch of that name is made again. Git remotes do not follow: fetch, then rename or re-track the branch in your clone. Give a branch with slashes URL-encoded in the path, e.g. feature%2Flogin." | |
| 1409 | 1415 | } | |
| 1416 | + | Op::CreateBranch => { | |
| 1417 | + | "Make a branch at another branch, a tag or a commit (from; the default branch when left out). Needs the Write role or higher, and the repository's rules for creating the branch hold. The name may not be taken, and may not be a folder of another branch's name or hold one (feature cannot sit beside feature/login). To restore a deleted branch, make it again at the commit delete_branch answered with. Returns the new branch and the commit it points to." | |
| 1418 | + | } | |
| 1419 | + | Op::DeleteBranch => { | |
| 1420 | + | "Delete a branch. Needs the Write role or higher, and the repository's rules for deleting the branch hold; the default branch is never deleted. Give head, the commit you saw it at, to leave it alone if someone has pushed since. Returns the branch as it was: its commits stay in the repository, so create_branch with that commit as from restores it. Open pull requests from it can no longer be updated or merged until it is back. Give a branch with slashes URL-encoded in the path, e.g. feature%2Flogin." | |
| 1421 | + | } | |
| 1410 | 1422 | Op::ArchiveRepo => { | |
| 1411 | 1423 | "Archive a repository: make it read-only. Needs the Admin role. Pushes and merges are refused, issues and pull requests are locked, and agents and workflows do not run. It can still be read, cloned and searched, and its deployments keep serving. unarchive_repo makes it writable again." | |
| 1412 | 1424 | } | |
| ⋯ | |||
| 2222 | 2234 | }), | |
| 2223 | 2235 | &["repo", "branch", "new_name"], | |
| 2224 | 2236 | ), | |
| 2237 | + | Op::CreateBranch => object( | |
| 2238 | + | json!({ | |
| 2239 | + | "repo": repo_schema(), | |
| 2240 | + | "name": { | |
| 2241 | + | "type": "string", | |
| 2242 | + | "description": "The new branch's name, e.g. \"feature/login\".", | |
| 2243 | + | }, | |
| 2244 | + | "from": { | |
| 2245 | + | "type": "string", | |
| 2246 | + | "description": "The branch, tag or commit to start it from. The default branch when left out.", | |
| 2247 | + | }, | |
| 2248 | + | }), | |
| 2249 | + | &["repo", "name"], | |
| 2250 | + | ), | |
| 2251 | + | Op::DeleteBranch => object( | |
| 2252 | + | json!({ | |
| 2253 | + | "repo": repo_schema(), | |
| 2254 | + | "branch": { | |
| 2255 | + | "type": "string", | |
| 2256 | + | "description": "The branch to delete, e.g. \"feature/login\". URL-encode slashes in the path.", | |
| 2257 | + | }, | |
| 2258 | + | "head": { | |
| 2259 | + | "type": "string", | |
| 2260 | + | "description": "The commit you saw it at. If it has moved since, it is left alone (409).", | |
| 2261 | + | }, | |
| 2262 | + | }), | |
| 2263 | + | &["repo", "branch"], | |
| 2264 | + | ), | |
| 2225 | 2265 | Op::ArchiveRepo | Op::UnarchiveRepo | Op::RestoreRepo => repo_only(), | |
| 2226 | 2266 | Op::SetRepoVisibility => object( | |
| 2227 | 2267 | json!({ | |
| ⋯ | |||
| 4224 | 4264 | ) | |
| 4225 | 4265 | .await | |
| 4226 | 4266 | } | |
| 4267 | + | Op::CreateBranch => { | |
| 4268 | + | pass( | |
| 4269 | + | repos, | |
| 4270 | + | "create_branch", | |
| 4271 | + | &json!({ | |
| 4272 | + | "actor": actor(), | |
| 4273 | + | "path": repo, | |
| 4274 | + | "name": text(input, "name"), | |
| 4275 | + | "from": text(input, "from"), | |
| 4276 | + | "surface": services.audit.surface, | |
| 4277 | + | }), | |
| 4278 | + | ) | |
| 4279 | + | .await | |
| 4280 | + | } | |
| 4281 | + | Op::DeleteBranch => { | |
| 4282 | + | pass( | |
| 4283 | + | repos, | |
| 4284 | + | "remove_branch", | |
| 4285 | + | &json!({ | |
| 4286 | + | "actor": actor(), | |
| 4287 | + | "path": repo, | |
| 4288 | + | "branch": text(input, "branch"), | |
| 4289 | + | "head": optional_text(input, "head"), | |
| 4290 | + | "surface": services.audit.surface, | |
| 4291 | + | }), | |
| 4292 | + | ) | |
| 4293 | + | .await | |
| 4294 | + | } | |
| 4227 | 4295 | Op::ArchiveRepo | Op::UnarchiveRepo => { | |
| 4228 | 4296 | pass( | |
| 4229 | 4297 | repos, | |
| 771 | 771 | }, | |
| 772 | 772 | "notes": "The repository after the rename; renaming the default branch changes `default_branch`. Send a branch name with slashes URL-encoded, as one path segment. Refused with `404` when there is no such branch, `409` when a branch named `new_name` exists, `422` when `new_name` is not a valid branch name, and `403` without the Write role, or without the Admin role for the default branch. See [Rename a branch](/guides/managing-repositories/#rename-a-branch)." | |
| 773 | 773 | }, | |
| 774 | + | "create_branch": { | |
| 775 | + | "request": { | |
| 776 | + | "name": "feature/login", | |
| 777 | + | "from": "main" | |
| 778 | + | }, | |
| 779 | + | "response": { | |
| 780 | + | "name": "feature/login", | |
| 781 | + | "hash": "9f2c4e1b7a3d5c6e8f0a1b2c3d4e5f6a7b8c9d0e" | |
| 782 | + | }, | |
| 783 | + | "notes": "The new branch and the commit it points to. `from` is a branch, a tag or a commit, the default branch when left out; to restore a deleted branch, send the `hash` that deleting it answered with. Refused with `404` when `from` names nothing, `409` when the name is taken or is a folder of another branch's name (or holds one), `422` when it is not a valid branch name, and `403` without the Write role or when the repository's rules refuse it. See [Branches](/guides/git/#branches)." | |
| 784 | + | }, | |
| 785 | + | "delete_branch": { | |
| 786 | + | "params": { | |
| 787 | + | "owner": "flagon-io", | |
| 788 | + | "name": "hello", | |
| 789 | + | "branch": "feature/login" | |
| 790 | + | }, | |
| 791 | + | "response": { | |
| 792 | + | "name": "feature/login", | |
| 793 | + | "hash": "9f2c4e1b7a3d5c6e8f0a1b2c3d4e5f6a7b8c9d0e" | |
| 794 | + | }, | |
| 795 | + | "notes": "The branch as it was. Its commits stay in the repository: `POST /repos/{owner}/{name}/branches` with this `hash` as `from` restores it. Add `?head=<sha>` to leave it alone (`409`) if it has moved from the commit you saw. Refused with `403` for the default branch, without the Write role, or when the repository's rules refuse it, and `404` when there is no such branch. Send a branch name with slashes URL-encoded, as one path segment. See [Branches](/guides/git/#branches)." | |
| 796 | + | }, | |
| 774 | 797 | "set_repo_visibility": { | |
| 775 | 798 | "request": { | |
| 776 | 799 | "private": true, |
| 161 | 161 | | Op::UnarchiveRepo | |
| 162 | 162 | | Op::SetRepoVisibility | |
| 163 | 163 | | Op::RestoreRepo => through::<repos::Repo>(op, sent), | |
| 164 | + | Op::CreateBranch | Op::DeleteBranch => through::<repos::Branch>(op, sent), | |
| 164 | 165 | Op::DeleteRepo => through::<repos::DeletedRepo>(op, sent), | |
| 165 | 166 | Op::ListDeletedRepos => through::<Vec<repos::DeletedRepo>>(op, sent), | |
| 166 | 167 | Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent), |
| 305 | 305 | Op::RenameBranch, | |
| 306 | 306 | &[], | |
| 307 | 307 | ), | |
| 308 | + | route("POST", "/repos/:owner/:name/branches", Op::CreateBranch, &[]), | |
| 309 | + | route("DELETE", "/repos/:owner/:name/branches/:branch", Op::DeleteBranch, &[("head", "head")]), | |
| 308 | 310 | route( | |
| 309 | 311 | "GET", | |
| 310 | 312 | "/repos/:owner/:name/settings", |
| 118 | 118 | a("update_release", Op::About(AboutOp::UpdateRelease), "Change a release's title, notes, draft or prerelease"), | |
| 119 | 119 | a("delete_release", Op::About(AboutOp::DeleteRelease), "Delete a release; its tag stays"), | |
| 120 | 120 | a("rename_branch", Op::RenameBranch, "Rename a branch"), | |
| 121 | + | a("create_branch", Op::CreateBranch, "Make a branch at a branch, tag or commit"), | |
| 122 | + | a("delete_branch", Op::DeleteBranch, "Delete a branch; make it again at its commit to restore it"), | |
| 121 | 123 | a("rename", Op::RenameRepo, "Rename it; old addresses redirect"), | |
| 122 | 124 | a("transfer", Op::TransferRepo, "Move it to another workspace you own"), | |
| 123 | 125 | a("mirror", Op::Mirrors(MirrorsOp::GetMirror), "Its remotes: what it mirrors or is mirrored to"), |
| 232 | 232 | seconds for the page to settle, then takes it as it is. | |
| 233 | 233 | - The screenshot is of the app's `g1t.page` address. A custom domain | |
| 234 | 234 | serves the same app, so it looks the same. | |
| 235 | − | - Until it is ready, or if it could not be taken, the overview shows a | |
| 236 | − | plain frame with the address. A failed screenshot is tried again when | |
| 237 | − | the overview is next opened, at most every five minutes. | |
| 238 | − | - Only people who can read the project's repository see it, like the | |
| 239 | − | rest of its deployments. | |
| 235 | + | - Until it is ready, or if it could not be taken, the overview shows the | |
| 236 | + | project's uploaded image, if it has one, or else a plain frame with the | |
| 237 | + | address. A failed screenshot is tried again when the overview is next | |
| 238 | + | opened, at most every five minutes. | |
| 239 | + | - Only people with a role on the project's repository see it, like the | |
| 240 | + | rest of its deployments; anyone else who can see the project sees its | |
| 241 | + | uploaded image or the frame. | |
| 242 | + | - With **Use the uploaded image instead of a screenshot** on, the | |
| 243 | + | uploaded image shows instead. | |
| 240 | 244 | - Screenshots are not charged. | |
| 241 | 245 | ||
| 246 | + | An app deployed elsewhere gets a screenshot of its production address | |
| 247 | + | too, and any project can show an uploaded image instead. See | |
| 248 | + | [production's picture](/guides/projects/#productions-picture). | |
| 249 | + | ||
| 242 | 250 | The [checklist on the overview](/guides/projects/#get-to-production) also | |
| 243 | 251 | tracks the first production deploy, a custom domain and a first preview. | |
| 244 | 252 |
| 176 | 176 | git push origin my-change | |
| 177 | 177 | ``` | |
| 178 | 178 | ||
| 179 | + | ### The Branches page | |
| 180 | + | ||
| 179 | 181 | A repository's **Branches** tab, `g1t.sh/<workspace>/<repo>/branches`, lists | |
| 180 | − | every branch: the default one first, then those with a commit in the last 90 | |
| 181 | − | days (**Active**), then the rest (**Stale**). Each shows its last commit, how | |
| 182 | − | many commits it is ahead of and behind the default branch, the pull request | |
| 183 | − | open on it with its checks, and its preview when it has one. A branch with | |
| 184 | − | no pull request links to opening one; one with nothing the default branch | |
| 185 | − | lacks (ahead `0`) says **Nothing to merge** instead. The counts are exact, | |
| 186 | − | merges included. g1t reads up to 1,000 commits of each history to find | |
| 187 | − | where the two meet; a branch that left the default branch further back | |
| 188 | − | than that shows no counts. Search narrows the list by name. | |
| 182 | + | its branches under five tabs, kept in the address as `?tab=`: | |
| 183 | + | ||
| 184 | + | | Tab | What it lists | | |
| 185 | + | | --- | --- | | |
| 186 | + | | **Overview** | The default branch, then the first five of each of the others below that has any: yours, active and stale, each with a link to the rest. | | |
| 187 | + | | **Yours** (`?tab=yours`) | Branches whose latest commit is yours. | | |
| 188 | + | | **Active** (`?tab=active`) | Branches with a commit in the last 90 days. | | |
| 189 | + | | **Stale** (`?tab=stale`) | Branches with no commit in the last 90 days. | | |
| 190 | + | | **All** (`?tab=all`) | Every branch but the default, newest commit first. | | |
| 191 | + | ||
| 192 | + | **Search branches** narrows any tab by name (`?q=`). Each branch shows: | |
| 193 | + | ||
| 194 | + | | Column | What it says | | |
| 195 | + | | --- | --- | | |
| 196 | + | | **Branch** | Its name, linked to its files, with a button that copies it; a globe when it has a preview. | | |
| 197 | + | | **Updated** | Who made its latest commit, and when, linked to the commit. | | |
| 198 | + | | **Check status** | All of its latest commit's checks at once: a check when they passed, a cross when one failed, a dot while any runs. Choose it to see each. | | |
| 199 | + | | **Behind / Ahead** | How many commits the default branch has that it lacks, and the other way round, with bars drawn against the largest count shown. The counts are exact, merges included. g1t reads up to 1,000 commits of each history to find where the two meet; a branch that left the default branch further back than that shows no counts. | | |
| 200 | + | | **Pull request** | Its open pull request, else its newest merged or closed one, with its state and number. | | |
| 201 | + | ||
| 202 | + | On a phone, the columns fold under the name. Up to 60 branches are read for | |
| 203 | + | a page, those with an open pull request first; search finds the rest. | |
| 204 | + | ||
| 205 | + | People with the Write role or higher can change branches from the page: | |
| 206 | + | ||
| 207 | + | - **New branch** makes a branch from another branch, or from a tag or a | |
| 208 | + | commit, which you type in. | |
| 209 | + | - The trash button deletes a branch. A branch with commits the default | |
| 210 | + | branch lacks, or whose counts could not be read, asks first; a merged | |
| 211 | + | one (nothing ahead, or its pull request merged) goes at once. It stays in | |
| 212 | + | the list as **Deleted**, with **Restore**, which makes it again at the | |
| 213 | + | same commit, until you leave the page. While it is gone, a pull request | |
| 214 | + | from it can be neither updated nor merged. A deletion is listed in the | |
| 215 | + | repository's **Activity** with the commit it pointed to, so it can still | |
| 216 | + | be restored later from the API. | |
| 217 | + | - The **⋯** menu opens a **New pull request** (the compare page against the | |
| 218 | + | default branch), the branch's **Activity**, the **View rules** that hold | |
| 219 | + | for it (Admin), and **Rename branch**. See | |
| 220 | + | [rename a branch](/guides/managing-repositories/#rename-a-branch) for what | |
| 221 | + | renaming does to pull requests, addresses and clones. | |
| 222 | + | ||
| 223 | + | The default branch is never deleted, and renaming it needs Admin. | |
| 224 | + | [Rules](/guides/rules/) hold for every one of these changes: a ruleset that | |
| 225 | + | restricts creating, deleting or renaming a branch refuses it here as it | |
| 226 | + | would a push, and says why. When nothing protects the default branch, | |
| 227 | + | people with the Admin role see a note saying so, with **Protect this | |
| 228 | + | branch**, which starts a [ruleset](/guides/rules/), and **Dismiss**, which | |
| 229 | + | this browser remembers for them and that repository. An archived | |
| 230 | + | repository, or a mirror that g1t has not taken over, offers none of these | |
| 231 | + | changes. | |
| 232 | + | ||
| 233 | + | From the API, a person's token with `repo:write` can do the same: | |
| 234 | + | ||
| 235 | + | | Route | MCP | What it does | | |
| 236 | + | | --- | --- | --- | | |
| 237 | + | | [`POST /repos/{owner}/{name}/branches`](/reference/api/repositories/create-branch/) | `repository` `create_branch` | Makes branch `name` at `from` (a branch, tag or commit; the default branch when left out). Answers with its `name` and `hash`. | | |
| 238 | + | | [`DELETE /repos/{owner}/{name}/branches/{branch}`](/reference/api/repositories/delete-branch/) | `repository` `delete_branch` | Deletes it and answers with the `hash` it pointed to. `?head=<sha>` leaves it alone (`409`) if it has moved since. | | |
| 239 | + | | [`POST /repos/{owner}/{name}/branches/{branch}/rename`](/reference/api/repositories/rename-branch/) | `repository` `rename_branch` | Renames it to `new_name`. | | |
| 240 | + | ||
| 241 | + | To restore a deleted branch, make it again with the `hash` deleting it | |
| 242 | + | answered with as `from`. Encode a branch name with slashes as one path | |
| 243 | + | segment: `feature/login` is `feature%2Flogin`. | |
| 244 | + | ||
| 245 | + | ```sh | |
| 246 | + | curl -X DELETE https://api.g1t.sh/repos/acme/rocket/branches/feature%2Flogin \ | |
| 247 | + | -H "Authorization: Bearer $G1T_TOKEN" | |
| 248 | + | # {"name": "feature/login", "hash": "9f2c4e1b…"} | |
| 249 | + | ||
| 250 | + | curl -X POST https://api.g1t.sh/repos/acme/rocket/branches \ | |
| 251 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 252 | + | -H "Content-Type: application/json" \ | |
| 253 | + | -d '{"name": "feature/login", "from": "9f2c4e1b…"}' | |
| 254 | + | ``` | |
| 255 | + | ||
| 256 | + | Making a branch sends `git.push`, and deleting one sends `git.deleted`, to | |
| 257 | + | [webhooks](/guides/webhooks/#events), as a push would; both | |
| 258 | + | are recorded in the [audit log](/guides/audit-log/) as `branch.created` and | |
| 259 | + | `branch.deleted`. g1t's token never deletes a branch. | |
| 189 | 260 | ||
| 190 | 261 | The **Tags** tab lists tags newest first, up to 100, each with its commit | |
| 191 | 262 | and a ZIP of its files. |
| 78 | 78 | ### Activity | |
| 79 | 79 | ||
| 80 | 80 | **Insights → Activity** lists, newest first, who pushed to which branch, | |
| 81 | − | created a branch or tag, merged a pull request, renamed a branch or changed | |
| 82 | − | the default branch, person or agent. | |
| 81 | + | created a branch or tag, deleted a branch (with the commit it pointed to), | |
| 82 | + | merged a pull request, renamed a branch or changed the default branch, | |
| 83 | + | person or agent. A branch's **Activity**, from its **⋯** menu on the | |
| 84 | + | [Branches page](/guides/git/#the-branches-page), keeps only what happened to | |
| 85 | + | that branch (`?branch=<name>`); each page of the repository's activity is | |
| 86 | + | read and filtered, so **Older** may be needed to reach earlier changes. | |
| 83 | 87 | ||
| 84 | 88 | ### Stars | |
| 85 | 89 | ||
| ⋯ | |||
| 111 | 115 | | Description, website, topics | Maintain | | |
| 112 | 116 | | How long artifacts are kept | Maintain | | |
| 113 | 117 | | Default branch | Admin | | |
| 118 | + | | Create, delete or restore a branch | Write; the default branch is never deleted. See [branches](/guides/git/#the-branches-page) | | |
| 114 | 119 | | Rename a branch | Write; Admin for the default branch | | |
| 115 | 120 | | Rename the repository | Admin | | |
| 116 | 121 | | Make it public or private | Admin, and the [member privileges](/guides/workspaces/#member-privileges) to allow it, or an owner | | |
| ⋯ | |||
| 190 | 195 | **New name**. | |
| 191 | 196 | 3. Choose **Rename branch**. | |
| 192 | 197 | ||
| 198 | + | Or, on the repository's [Branches page](/guides/git/#the-branches-page), | |
| 199 | + | open the branch's **⋯** menu and choose **Rename branch**. | |
| 200 | + | ||
| 193 | 201 | Renaming the default branch needs Admin. It stays the default under its | |
| 194 | 202 | new name. When a branch is renamed: | |
| 195 | 203 | ||
| ⋯ | |||
| 491 | 499 | | Rename | `repo.renamed` | | |
| 492 | 500 | | Default branch | `repo.default_branch_changed` | | |
| 493 | 501 | | Branch rename | `branch.renamed` | | |
| 502 | + | | Branch made or deleted | `git.push`, `git.deleted`; in the audit log, `branch.created`, `branch.deleted` | | |
| 494 | 503 | | Archive, unarchive | `repo.archived`, `repo.unarchived` | | |
| 495 | 504 | | Transfer | `repo.transferred` | | |
| 496 | 505 | | Delete, restore, purge | `repo.deleted`, `repo.restored`, `repo.purged` | | |
| 54 | 54 | | Part | What it shows | | |
| 55 | 55 | | --- | --- | | |
| 56 | 56 | | **What it is** | A badge, such as **App, deployed elsewhere** or **Library**, beside its homepage and docs. People who can change its settings open the badge to change [what it is](#what-a-project-is) in one click. | | |
| 57 | − | | **Production** | For an app [deployed on g1t](/guides/deployments/): a screenshot of the live site, which opens it; its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. | | |
| 57 | + | | **Production** | [Production's picture](#productions-picture), which opens the live site, beside the rest. For an app [deployed on g1t](/guides/deployments/): its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with its latest reported production deployment or the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. | | |
| 58 | 58 | | **Packages** | For a library or a tool, in place of Production: the packages its repository publishes, each with its latest version and how to install it, or how to publish a first one. | | |
| 59 | 59 | | **Documentation**, **Homepage** | For docs, where they are read; for anything else, its homepage. | | |
| 60 | 60 | | **Get to production**, **Ship a release** or **Get started** | The [checklist](#the-checklist) for what the project is, until every step is done or you dismiss it. | | |
| ⋯ | |||
| 65 | 65 | | **About** | Its description, its [links](#links), what it is, and its latest release (its newest tag). People who can change its settings edit the description and links from here. | | |
| 66 | 66 | | **Health**, **Clone** | How often checks pass (on pull requests, or the project's workflow runs when it ships without them), recent builds and open issues by age; the clone address. | | |
| 67 | 67 | ||
| 68 | + | ### Production's picture | |
| 69 | + | ||
| 70 | + | The production card always has a picture of the app, wherever it runs. | |
| 71 | + | It is the first of these there is: | |
| 72 | + | ||
| 73 | + | 1. **A screenshot of production.** For an app deployed on g1t, g1t takes | |
| 74 | + | one each time production goes live (see | |
| 75 | + | [the production screenshot](/guides/deployments/#the-production-screenshot)); | |
| 76 | + | people with a role on the repository see it, like the rest of its | |
| 77 | + | deployments. For an app deployed elsewhere, g1t takes one of | |
| 78 | + | production's address: the address its latest production deployment | |
| 79 | + | reported to g1t gives, or else the one set for it under **What it is**. | |
| 80 | + | Anyone who can see the project sees it. | |
| 81 | + | 2. **An image someone uploaded**, under **Settings** → **General** → | |
| 82 | + | **Production image**. | |
| 83 | + | 3. **A plain frame** with production's address. People who can change | |
| 84 | + | the project's settings see **Add an image of your app** on it, which | |
| 85 | + | opens **Production image**. | |
| 86 | + | ||
| 87 | + | A screenshot of production deployed elsewhere is taken: | |
| 88 | + | ||
| 89 | + | - when its address is set or changed; | |
| 90 | + | - when a production deployment of it is reported to g1t and succeeds, | |
| 91 | + | through the [API](/guides/deployments/) or by a workflow job with an | |
| 92 | + | `environment:`; | |
| 93 | + | - otherwise about once a day, when the one kept is a day old. Opening the | |
| 94 | + | overview after that also takes a new one, in the background, while the | |
| 95 | + | old one shows. | |
| 96 | + | ||
| 97 | + | g1t waits up to 12 seconds for the page to settle, as a 1280 by 800 | |
| 98 | + | browser window sees it, then takes it as it is. Only public `http` and | |
| 99 | + | `https` addresses on their usual ports are visited: an address on a | |
| 100 | + | private network, a loopback or link-local address, or one of g1t's own | |
| 101 | + | service addresses (other than `g1t.sh` itself) gets the uploaded image | |
| 102 | + | or the frame instead. Screenshots are not charged. | |
| 103 | + | ||
| 104 | + | #### Uploading an image | |
| 105 | + | ||
| 106 | + | **Production image** takes a PNG, JPEG or WebP of at most 5 MB; wide | |
| 107 | + | images fit best, such as 1280 by 800. It is checked by its contents, not | |
| 108 | + | its name, and served from g1t's user-content address | |
| 109 | + | (`g1tusercontent.com` on g1t.sh), never from the site itself. Its address | |
| 110 | + | is made from its contents, so the card shows a new image as soon as it | |
| 111 | + | is uploaded. Anyone with an image's address can open it, even for a | |
| 112 | + | private project, so upload only what you would show. | |
| 113 | + | ||
| 114 | + | - The uploaded image shows whenever there is no screenshot: before the | |
| 115 | + | first one, when one cannot be taken, or for an app with no production | |
| 116 | + | address. | |
| 117 | + | - **Use the uploaded image instead of a screenshot** shows it in place of | |
| 118 | + | any screenshot. While it is on, g1t takes no screenshots of production | |
| 119 | + | deployed elsewhere. | |
| 120 | + | - **Remove** takes it away; the card goes back to the screenshot or the | |
| 121 | + | frame. | |
| 122 | + | ||
| 123 | + | Anyone who can change the project's settings (the **Maintain** role on | |
| 124 | + | its repository and up, as for production's address) can upload, replace | |
| 125 | + | or remove it. | |
| 126 | + | ||
| 68 | 127 | ### The checklist | |
| 69 | 128 | ||
| 70 | 129 | People with a role on its repository see a card that counts what the | |
| ⋯ | |||
| 216 | 275 | ||
| 217 | 276 | | Tab | What it holds | | |
| 218 | 277 | | --- | --- | | |
| 219 | − | | **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), and its [links](#links). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. | | |
| 278 | + | | **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), its [links](#links), and its [production image](#uploading-an-image). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. | | |
| 220 | 279 | | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). | | |
| 221 | 280 | | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). | | |
| 222 | 281 | | **Agents** | How g1t's agents pick up work here, and what they read first. | | |
| 289 | 289 | | [`update_release`](/reference/api/releases/update-release/) | Change its `release_name`, `body`, `draft` or `prerelease`. Write role. | `repo`, `id` | `repo:write` | | |
| 290 | 290 | | [`delete_release`](/reference/api/releases/delete-release/) | Delete a release; its tag stays. Write role. | `repo`, `id` | `repo:write` | | |
| 291 | 291 | | [`rename_branch`](/reference/api/repositories/rename-branch/) | Rename a branch; its pull requests follow, and web addresses that name the old branch redirect. Write role; the default branch needs Admin. | `repo`, `branch`, `new_name` | `repo:write` | | |
| 292 | + | | [`create_branch`](/reference/api/repositories/create-branch/) | Make a branch at another branch, a tag or a commit (`from`; the default branch when left out). Making one again at the commit `delete_branch` answered with restores it. Write role; the rules for creating it hold. | `repo`, `name` | `repo:write` | | |
| 293 | + | | [`delete_branch`](/reference/api/repositories/delete-branch/) | Delete a branch, never the default; answers with the commit it pointed to. With `head`, a branch that has moved is left alone. Write role; the rules for deleting it hold. People only: g1t's token never deletes a branch. | `repo`, `branch` | `repo:write` | | |
| 292 | 294 | | [`rename`](/reference/api/repositories/rename-repo/) | Give it a new name in its workspace; the old address redirects. Admin role. | `repo`, `name` | `repo:admin` | | |
| 293 | 295 | | [`transfer`](/reference/api/repositories/transfer-repo/) | Move it to another workspace, keeping its name; the old address redirects. An owner, or an Admin when the member privileges allow it; and someone who can create a repository in the other workspace. See [transferring a repository](/guides/transferring-repositories/). | `repo`, `to` | `repo:admin` | | |
| 294 | 296 | | [`archive`](/reference/api/repositories/archive-repo/) | Make it read-only: pushes and merges are refused, issues and pull requests are locked, agents and workflows stop. Admin role. | `repo` | `repo:admin` | |
Binary or large file; its contents are not shown.
| 1 | + | /** | |
| 2 | + | * The Branches page's list: each branch with its latest commit's author | |
| 3 | + | * and time, its checks, how far it is behind and ahead of the default | |
| 4 | + | * branch, and its pull request, and, for people who can push, deleting it | |
| 5 | + | * (and restoring it for the rest of the visit), renaming it, and making a | |
| 6 | + | * new branch. The page's action (routes/repo/branches.tsx) does the work. | |
| 7 | + | */ | |
| 8 | + | import { | |
| 9 | + | Activity, | |
| 10 | + | Check, | |
| 11 | + | Copy, | |
| 12 | + | GitBranch, | |
| 13 | + | GitBranchPlus, | |
| 14 | + | GitPullRequestArrow, | |
| 15 | + | Globe, | |
| 16 | + | LoaderCircle, | |
| 17 | + | MoreHorizontal, | |
| 18 | + | PenLine, | |
| 19 | + | ShieldCheck, | |
| 20 | + | ShieldAlert, | |
| 21 | + | Trash2, | |
| 22 | + | Undo2, | |
| 23 | + | X, | |
| 24 | + | } from "lucide-react"; | |
| 25 | + | import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; | |
| 26 | + | import { Link, useFetcher } from "react-router"; | |
| 27 | + | ||
| 28 | + | import type { ActiveBranch } from "./branches"; | |
| 29 | + | import type { ChecksSource } from "./commit-checks"; | |
| 30 | + | import { CommitChecksBadge } from "./commit-checks"; | |
| 31 | + | import { CommitAvatars } from "./commit-person"; | |
| 32 | + | import { host } from "./deploy"; | |
| 33 | + | import { CopyLine, TimeAgo } from "./ui"; | |
| 34 | + | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; | |
| 35 | + | import { | |
| 36 | + | AlertDialog, | |
| 37 | + | AlertDialogAction, | |
| 38 | + | AlertDialogCancel, | |
| 39 | + | AlertDialogContent, | |
| 40 | + | AlertDialogDescription, | |
| 41 | + | AlertDialogFooter, | |
| 42 | + | AlertDialogHeader, | |
| 43 | + | AlertDialogTitle, | |
| 44 | + | } from "./ui/alert-dialog"; | |
| 45 | + | import { Badge } from "./ui/badge"; | |
| 46 | + | import { Button } from "./ui/button"; | |
| 47 | + | import { Combobox } from "./ui/combobox"; | |
| 48 | + | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; | |
| 49 | + | import { | |
| 50 | + | DropdownMenu, | |
| 51 | + | DropdownMenuContent, | |
| 52 | + | DropdownMenuItem, | |
| 53 | + | DropdownMenuSeparator, | |
| 54 | + | DropdownMenuTrigger, | |
| 55 | + | } from "./ui/dropdown-menu"; | |
| 56 | + | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 57 | + | import { Hint } from "./ui/hint"; | |
| 58 | + | import { Input } from "./ui/input"; | |
| 59 | + | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table"; | |
| 60 | + | import { PullIcon } from "./work-icons"; | |
| 61 | + | import { isMerged } from "../lib/branches"; | |
| 62 | + | ||
| 63 | + | /** What the page's action answers. */ | |
| 64 | + | export type BranchActionData = { intent: string; branch?: string; hash?: string; error?: string }; | |
| 65 | + | ||
| 66 | + | /** A row as the list shows it: a branch, or one deleted on this visit. */ | |
| 67 | + | export type BranchRowData = ActiveBranch & { deleted?: boolean }; | |
| 68 | + | ||
| 69 | + | /** What every row needs to know about the page. */ | |
| 70 | + | export type BranchListContext = { | |
| 71 | + | base: string; | |
| 72 | + | main: string; | |
| 73 | + | checks: ChecksSource; | |
| 74 | + | /** The longest behind or ahead count shown: a full bar. */ | |
| 75 | + | scale: number; | |
| 76 | + | /** May push: make, delete, restore and rename branches. */ | |
| 77 | + | canPush: boolean; | |
| 78 | + | /** May rename the default branch (Admin). */ | |
| 79 | + | canRenameDefault: boolean; | |
| 80 | + | /** May see the rules (Admin). */ | |
| 81 | + | canViewRules: boolean; | |
| 82 | + | onDeleted: (row: ActiveBranch) => void; | |
| 83 | + | onRestored: (name: string) => void; | |
| 84 | + | onError: (message: string) => void; | |
| 85 | + | }; | |
| 86 | + | ||
| 87 | + | /** A branch name in an address: each part encoded, its slashes kept. */ | |
| 88 | + | export function refPath(name: string): string { | |
| 89 | + | return name.split("/").map(encodeURIComponent).join("/"); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | /** A small copy button: a check for a moment once copied. */ | |
| 93 | + | function CopyButton({ text, label }: { text: string; label: string }) { | |
| 94 | + | const [copied, setCopied] = useState(false); | |
| 95 | + | return ( | |
| 96 | + | <Hint label={copied ? "Copied" : label}> | |
| 97 | + | <Button | |
| 98 | + | type="button" | |
| 99 | + | variant="ghost" | |
| 100 | + | size="inline" | |
| 101 | + | aria-label={label} | |
| 102 | + | className="p-1 text-faint opacity-70 group-hover/row:opacity-100 focus-visible:opacity-100" | |
| 103 | + | onClick={() => { | |
| 104 | + | void navigator.clipboard.writeText(text).then(() => { | |
| 105 | + | setCopied(true); | |
| 106 | + | setTimeout(() => setCopied(false), 1500); | |
| 107 | + | }); | |
| 108 | + | }} | |
| 109 | + | > | |
| 110 | + | {copied ? <Check size={13} className="text-success" /> : <Copy size={13} />} | |
| 111 | + | </Button> | |
| 112 | + | </Hint> | |
| 113 | + | ); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /** The branch's name in a code chip, linked to its files, with a copy button. */ | |
| 117 | + | function BranchName({ row, base, isDefault }: { row: BranchRowData; base: string; isDefault: boolean }) { | |
| 118 | + | return ( | |
| 119 | + | <span className="flex min-w-0 items-center gap-1"> | |
| 120 | + | {row.deleted ? ( | |
| 121 | + | <code className="truncate rounded-md bg-raised px-1.5 py-0.5 font-mono text-[0.8125rem] text-faint line-through">{row.name}</code> | |
| 122 | + | ) : ( | |
| 123 | + | <Link | |
| 124 | + | to={`${base}/tree/${encodeURIComponent(row.name)}`} | |
| 125 | + | className="min-w-0 truncate rounded-md bg-accent/10 px-1.5 py-0.5 font-mono text-[0.8125rem] font-medium text-accent hover:bg-accent/20" | |
| 126 | + | > | |
| 127 | + | {row.name} | |
| 128 | + | </Link> | |
| 129 | + | )} | |
| 130 | + | {!row.deleted && <CopyButton text={row.name} label="Copy the branch name" />} | |
| 131 | + | {isDefault && <Badge className="ml-1">default</Badge>} | |
| 132 | + | {row.preview && !row.deleted && ( | |
| 133 | + | <Hint label={`Preview at ${host(row.preview)}`}> | |
| 134 | + | <a href={row.preview} aria-label={`Preview at ${host(row.preview)}`} className="ml-1 inline-flex rounded p-0.5 text-accent hover:bg-raised"> | |
| 135 | + | <Globe size={13} /> | |
| 136 | + | </a> | |
| 137 | + | </Hint> | |
| 138 | + | )} | |
| 139 | + | </span> | |
| 140 | + | ); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /** Who made the latest commit, and when, linked to it. */ | |
| 144 | + | function Updated({ row, base }: { row: BranchRowData; base: string }) { | |
| 145 | + | if (!row.commit) return <span className="text-faint">—</span>; | |
| 146 | + | return ( | |
| 147 | + | <Hint label={row.commit.message}> | |
| 148 | + | <Link to={`${base}/commit/${row.commit.hash}`} className="inline-flex items-center gap-1.5 whitespace-nowrap text-muted hover:text-fg"> | |
| 149 | + | <CommitAvatars commit={row.commit} size={18} max={2} /> | |
| 150 | + | <TimeAgo at={row.commit.at} /> | |
| 151 | + | </Link> | |
| 152 | + | </Hint> | |
| 153 | + | ); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | function driftWords(drift: NonNullable<ActiveBranch["drift"]>, main: string): string { | |
| 157 | + | return `${drift.behind} ${drift.behind === 1 ? "commit" : "commits"} behind ${main}, ${drift.ahead} ahead`; | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /** Behind on the left, ahead on the right, each with a bar against the longest shown. */ | |
| 161 | + | function DriftBars({ drift, scale, main }: { drift: ActiveBranch["drift"]; scale: number; main: string }) { | |
| 162 | + | if (!drift) return <span className="text-faint">—</span>; | |
| 163 | + | const width = (count: number) => `${count === 0 ? 0 : Math.max(6, Math.round((count / scale) * 100))}%`; | |
| 164 | + | return ( | |
| 165 | + | <Hint label={driftWords(drift, main)}> | |
| 166 | + | <span className="inline-flex w-28 flex-col gap-1" tabIndex={0}> | |
| 167 | + | <span className="sr-only">{driftWords(drift, main)}</span> | |
| 168 | + | <span className="grid grid-cols-2 font-mono text-xs tabular-nums" aria-hidden> | |
| 169 | + | <span className="border-r border-line pr-1.5 text-right text-muted">{drift.behind}</span> | |
| 170 | + | <span className="pl-1.5 text-muted">{drift.ahead}</span> | |
| 171 | + | </span> | |
| 172 | + | <span className="grid h-1 grid-cols-2" aria-hidden> | |
| 173 | + | <span className="flex justify-end border-r border-line"> | |
| 174 | + | <span className="h-full rounded-l-full bg-faint/60" style={{ width: width(drift.behind) }} /> | |
| 175 | + | </span> | |
| 176 | + | <span className="flex"> | |
| 177 | + | <span className="h-full rounded-r-full bg-accent/70" style={{ width: width(drift.ahead) }} /> | |
| 178 | + | </span> | |
| 179 | + | </span> | |
| 180 | + | </span> | |
| 181 | + | </Hint> | |
| 182 | + | ); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | const PULL_STATE = { open: "Open", draft: "Draft", merged: "Merged", closed: "Closed" } as const; | |
| 186 | + | ||
| 187 | + | /** The branch's pull request: its state and number, linked. */ | |
| 188 | + | function PullLink({ pull, base }: { pull: ActiveBranch["pull"]; base: string }) { | |
| 189 | + | if (!pull) return null; | |
| 190 | + | return ( | |
| 191 | + | <Hint label={pull.title}> | |
| 192 | + | <Link | |
| 193 | + | to={`${base}/pull/${pull.number}`} | |
| 194 | + | className="inline-flex items-center gap-1 rounded-md border border-line px-1.5 py-0.5 text-xs whitespace-nowrap text-muted hover:border-line-strong hover:text-fg" | |
| 195 | + | > | |
| 196 | + | <PullIcon status={pull.status} size={13} />#{pull.number} | |
| 197 | + | <span className="sr-only"> | |
| 198 | + | {PULL_STATE[pull.status]}: {pull.title} | |
| 199 | + | </span> | |
| 200 | + | </Link> | |
| 201 | + | </Hint> | |
| 202 | + | ); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /** | |
| 206 | + | * A fetcher for one row's actions, calling `done` once with each answer | |
| 207 | + | * that comes back. It is called as soon as the answer is in, while the | |
| 208 | + | * page reloads: a deleted branch's row is gone once it has, and with it | |
| 209 | + | * this fetcher. | |
| 210 | + | */ | |
| 211 | + | function useBranchAction(done: (data: BranchActionData) => void) { | |
| 212 | + | const fetcher = useFetcher<BranchActionData>(); | |
| 213 | + | const handled = useRef<BranchActionData | undefined>(undefined); | |
| 214 | + | const latest = useRef(done); | |
| 215 | + | latest.current = done; | |
| 216 | + | useEffect(() => { | |
| 217 | + | if (fetcher.state === "submitting" || !fetcher.data || handled.current === fetcher.data) return; | |
| 218 | + | handled.current = fetcher.data; | |
| 219 | + | latest.current(fetcher.data); | |
| 220 | + | }, [fetcher.state, fetcher.data]); | |
| 221 | + | return fetcher; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /** Renaming a branch, with what it does to pull requests, addresses and clones. */ | |
| 225 | + | function RenameDialog({ | |
| 226 | + | row, | |
| 227 | + | main, | |
| 228 | + | open, | |
| 229 | + | onOpenChange, | |
| 230 | + | }: { | |
| 231 | + | row: BranchRowData; | |
| 232 | + | main: string; | |
| 233 | + | open: boolean; | |
| 234 | + | onOpenChange: (open: boolean) => void; | |
| 235 | + | }) { | |
| 236 | + | const [to, setTo] = useState(row.name); | |
| 237 | + | const [error, setError] = useState<string | null>(null); | |
| 238 | + | const fetcher = useBranchAction((data) => { | |
| 239 | + | if (data.error) setError(data.error); | |
| 240 | + | else onOpenChange(false); | |
| 241 | + | }); | |
| 242 | + | const busy = fetcher.state !== "idle"; | |
| 243 | + | const next = to.trim(); | |
| 244 | + | const isDefault = row.name === main; | |
| 245 | + | const openPull = row.pull && (row.pull.status === "open" || row.pull.status === "draft") ? row.pull : null; | |
| 246 | + | return ( | |
| 247 | + | <Dialog | |
| 248 | + | open={open} | |
| 249 | + | onOpenChange={(now) => { | |
| 250 | + | if (now) { | |
| 251 | + | setTo(row.name); | |
| 252 | + | setError(null); | |
| 253 | + | } | |
| 254 | + | onOpenChange(now); | |
| 255 | + | }} | |
| 256 | + | > | |
| 257 | + | <DialogContent> | |
| 258 | + | <fetcher.Form method="post" className="grid gap-4"> | |
| 259 | + | <DialogHeader> | |
| 260 | + | <DialogTitle>Rename {row.name}</DialogTitle> | |
| 261 | + | <DialogDescription> | |
| 262 | + | {openPull ? ( | |
| 263 | + | <> | |
| 264 | + | Pull request #{openPull.number} from it follows it to the new name, and stays open.{" "} | |
| 265 | + | </> | |
| 266 | + | ) : ( | |
| 267 | + | <>Open pull requests from it follow it to the new name. </> | |
| 268 | + | )} | |
| 269 | + | Addresses that name {row.name} redirect to the new name until a branch called {row.name} is made again. | |
| 270 | + | {isDefault && " It stays the default branch, and pull requests into it merge into the new name."} | |
| 271 | + | </DialogDescription> | |
| 272 | + | </DialogHeader> | |
| 273 | + | <input type="hidden" name="intent" value="rename" /> | |
| 274 | + | <input type="hidden" name="branch" value={row.name} /> | |
| 275 | + | <Field> | |
| 276 | + | <FieldLabel htmlFor={`rename-${row.name}`}>New name</FieldLabel> | |
| 277 | + | <Input | |
| 278 | + | id={`rename-${row.name}`} | |
| 279 | + | name="to" | |
| 280 | + | value={to} | |
| 281 | + | onChange={(event) => setTo(event.target.value)} | |
| 282 | + | maxLength={200} | |
| 283 | + | spellCheck={false} | |
| 284 | + | autoCapitalize="off" | |
| 285 | + | className="font-mono" | |
| 286 | + | aria-invalid={error ? true : undefined} | |
| 287 | + | autoFocus | |
| 288 | + | /> | |
| 289 | + | {error && <FieldError>{error}</FieldError>} | |
| 290 | + | </Field> | |
| 291 | + | <div className="grid gap-1.5"> | |
| 292 | + | <p className="text-xs text-muted">Clones keep the old name until they catch up:</p> | |
| 293 | + | <CopyLine | |
| 294 | + | text={`git branch -m ${row.name} ${next || "<new-name>"} && git fetch origin && git branch -u origin/${next || "<new-name>"} ${next || "<new-name>"}`} | |
| 295 | + | /> | |
| 296 | + | </div> | |
| 297 | + | <DialogFooter> | |
| 298 | + | <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> | |
| 299 | + | Cancel | |
| 300 | + | </Button> | |
| 301 | + | <Button type="submit" disabled={busy || next === "" || next === row.name}> | |
| 302 | + | {busy && <LoaderCircle size={14} className="animate-spin" aria-hidden />} | |
| 303 | + | Rename branch | |
| 304 | + | </Button> | |
| 305 | + | </DialogFooter> | |
| 306 | + | </fetcher.Form> | |
| 307 | + | </DialogContent> | |
| 308 | + | </Dialog> | |
| 309 | + | ); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | /** The ⋯ menu of a row: a pull request, its activity, its rules, renaming it. */ | |
| 313 | + | function RowMenu({ row, context, onRename }: { row: BranchRowData; context: BranchListContext; onRename: () => void }) { | |
| 314 | + | const { base, main, canPush, canRenameDefault, canViewRules } = context; | |
| 315 | + | const isDefault = row.name === main; | |
| 316 | + | const canRename = isDefault ? canRenameDefault : canPush; | |
| 317 | + | return ( | |
| 318 | + | <DropdownMenu> | |
| 319 | + | <Hint label="More"> | |
| 320 | + | <DropdownMenuTrigger asChild> | |
| 321 | + | <Button type="button" variant="ghost" size="icon-xs" aria-label={`More for ${row.name}`}> | |
| 322 | + | <MoreHorizontal size={15} /> | |
| 323 | + | </Button> | |
| 324 | + | </DropdownMenuTrigger> | |
| 325 | + | </Hint> | |
| 326 | + | <DropdownMenuContent align="end"> | |
| 327 | + | {!isDefault && ( | |
| 328 | + | <DropdownMenuItem asChild> | |
| 329 | + | <Link to={`${base}/compare/${refPath(main)}...${refPath(row.name)}`}> | |
| 330 | + | <GitPullRequestArrow /> | |
| 331 | + | New pull request | |
| 332 | + | </Link> | |
| 333 | + | </DropdownMenuItem> | |
| 334 | + | )} | |
| 335 | + | <DropdownMenuItem asChild> | |
| 336 | + | <Link to={`${base}/activity?branch=${encodeURIComponent(row.name)}`}> | |
| 337 | + | <Activity /> | |
| 338 | + | Activity | |
| 339 | + | </Link> | |
| 340 | + | </DropdownMenuItem> | |
| 341 | + | {canViewRules && ( | |
| 342 | + | <DropdownMenuItem asChild> | |
| 343 | + | <Link to={`${base}/settings/rules?branch=${encodeURIComponent(row.name)}`}> | |
| 344 | + | <ShieldCheck /> | |
| 345 | + | View rules | |
| 346 | + | </Link> | |
| 347 | + | </DropdownMenuItem> | |
| 348 | + | )} | |
| 349 | + | {canRename && ( | |
| 350 | + | <> | |
| 351 | + | <DropdownMenuSeparator /> | |
| 352 | + | <DropdownMenuItem onSelect={onRename}> | |
| 353 | + | <PenLine /> | |
| 354 | + | Rename branch | |
| 355 | + | </DropdownMenuItem> | |
| 356 | + | </> | |
| 357 | + | )} | |
| 358 | + | </DropdownMenuContent> | |
| 359 | + | </DropdownMenu> | |
| 360 | + | ); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | /** The trash button; branches with work the default branch lacks ask first. */ | |
| 364 | + | function DeleteButton({ row, context }: { row: BranchRowData; context: BranchListContext }) { | |
| 365 | + | const [asking, setAsking] = useState(false); | |
| 366 | + | const fetcher = useBranchAction((data) => { | |
| 367 | + | if (data.error) context.onError(data.error); | |
| 368 | + | else context.onDeleted(row); | |
| 369 | + | }); | |
| 370 | + | const busy = fetcher.state !== "idle"; | |
| 371 | + | const remove = () => fetcher.submit({ intent: "delete", branch: row.name, head: row.head }, { method: "post" }); | |
| 372 | + | const ahead = row.drift?.ahead ?? null; | |
| 373 | + | const openPull = row.pull && (row.pull.status === "open" || row.pull.status === "draft") ? row.pull : null; | |
| 374 | + | return ( | |
| 375 | + | <> | |
| 376 | + | <Hint label="Delete branch"> | |
| 377 | + | <Button | |
| 378 | + | type="button" | |
| 379 | + | variant="ghost" | |
| 380 | + | size="icon-xs" | |
| 381 | + | aria-label={`Delete ${row.name}`} | |
| 382 | + | disabled={busy} | |
| 383 | + | className="hover:text-danger" | |
| 384 | + | onClick={() => (isMerged(row) ? remove() : setAsking(true))} | |
| 385 | + | > | |
| 386 | + | {busy ? <LoaderCircle size={14} className="animate-spin" /> : <Trash2 size={14} />} | |
| 387 | + | </Button> | |
| 388 | + | </Hint> | |
| 389 | + | <AlertDialog open={asking} onOpenChange={setAsking}> | |
| 390 | + | <AlertDialogContent> | |
| 391 | + | <AlertDialogHeader> | |
| 392 | + | <AlertDialogTitle>Delete {row.name}?</AlertDialogTitle> | |
| 393 | + | <AlertDialogDescription> | |
| 394 | + | {ahead == null | |
| 395 | + | ? `It may have commits that are not on ${context.main}.` | |
| 396 | + | : `It has ${ahead} ${ahead === 1 ? "commit" : "commits"} that ${ahead === 1 ? "is" : "are"} not on ${context.main}.`}{" "} | |
| 397 | + | {openPull && `Pull request #${openPull.number} from it cannot be updated or merged while it is gone. `} | |
| 398 | + | You can restore it from this page until you leave it. | |
| 399 | + | </AlertDialogDescription> | |
| 400 | + | </AlertDialogHeader> | |
| 401 | + | <AlertDialogFooter> | |
| 402 | + | <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| 403 | + | <AlertDialogAction | |
| 404 | + | className="border border-danger/40 bg-transparent text-danger hover:border-danger hover:bg-danger/10" | |
| 405 | + | onClick={remove} | |
| 406 | + | > | |
| 407 | + | Delete branch | |
| 408 | + | </AlertDialogAction> | |
| 409 | + | </AlertDialogFooter> | |
| 410 | + | </AlertDialogContent> | |
| 411 | + | </AlertDialog> | |
| 412 | + | </> | |
| 413 | + | ); | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | /** Making a deleted branch again at the commit it pointed to. */ | |
| 417 | + | function RestoreButton({ row, context }: { row: BranchRowData; context: BranchListContext }) { | |
| 418 | + | const fetcher = useBranchAction((data) => { | |
| 419 | + | if (data.error) context.onError(data.error); | |
| 420 | + | else context.onRestored(row.name); | |
| 421 | + | }); | |
| 422 | + | const busy = fetcher.state !== "idle"; | |
| 423 | + | return ( | |
| 424 | + | <Button | |
| 425 | + | type="button" | |
| 426 | + | variant="outline" | |
| 427 | + | size="xs" | |
| 428 | + | disabled={busy} | |
| 429 | + | onClick={() => fetcher.submit({ intent: "restore", branch: row.name, head: row.head }, { method: "post" })} | |
| 430 | + | > | |
| 431 | + | {busy ? <LoaderCircle size={13} className="animate-spin" /> : <Undo2 size={13} />} | |
| 432 | + | Restore | |
| 433 | + | </Button> | |
| 434 | + | ); | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | /** A row's buttons: restore once deleted; otherwise delete and the menu. */ | |
| 438 | + | function RowActions({ row, context }: { row: BranchRowData; context: BranchListContext }) { | |
| 439 | + | const [renaming, setRenaming] = useState(false); | |
| 440 | + | if (row.deleted) return context.canPush ? <RestoreButton row={row} context={context} /> : null; | |
| 441 | + | const isDefault = row.name === context.main; | |
| 442 | + | return ( | |
| 443 | + | <span className="flex items-center justify-end gap-0.5"> | |
| 444 | + | {context.canPush && !isDefault && <DeleteButton row={row} context={context} />} | |
| 445 | + | <RowMenu row={row} context={context} onRename={() => setRenaming(true)} /> | |
| 446 | + | {renaming && <RenameDialog row={row} main={context.main} open={renaming} onOpenChange={setRenaming} />} | |
| 447 | + | </span> | |
| 448 | + | ); | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | /** One branch as a table row; below `md`, its details fold under the name. */ | |
| 452 | + | function BranchRow({ row, context }: { row: BranchRowData; context: BranchListContext }) { | |
| 453 | + | const { base, main, checks, scale } = context; | |
| 454 | + | const isDefault = row.name === main; | |
| 455 | + | const sha = row.commit?.hash ?? row.head; | |
| 456 | + | return ( | |
| 457 | + | <TableRow className={row.deleted ? "group/row bg-raised/30" : "group/row hover:bg-raised/30"}> | |
| 458 | + | <TableCell className="max-w-0 py-2.5 align-middle md:w-[40%]"> | |
| 459 | + | <BranchName row={row} base={base} isDefault={isDefault} /> | |
| 460 | + | {/* What the other columns say, for a narrow screen. */} | |
| 461 | + | <span className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs md:hidden"> | |
| 462 | + | {row.deleted ? ( | |
| 463 | + | <Badge tone="danger">Deleted</Badge> | |
| 464 | + | ) : ( | |
| 465 | + | <> | |
| 466 | + | <Updated row={row} base={base} /> | |
| 467 | + | {sha && <CommitChecksBadge checks={checks} sha={sha} className="size-5" />} | |
| 468 | + | {row.drift && !isDefault && ( | |
| 469 | + | <span className="font-mono text-muted tabular-nums"> | |
| 470 | + | <span className="sr-only">{driftWords(row.drift, main)}</span> | |
| 471 | + | <span aria-hidden> | |
| 472 | + | ↓{row.drift.behind} ↑{row.drift.ahead} | |
| 473 | + | </span> | |
| 474 | + | </span> | |
| 475 | + | )} | |
| 476 | + | <PullLink pull={row.pull} base={base} /> | |
| 477 | + | </> | |
| 478 | + | )} | |
| 479 | + | </span> | |
| 480 | + | </TableCell> | |
| 481 | + | {row.deleted ? ( | |
| 482 | + | <TableCell colSpan={4} className="align-middle max-md:hidden"> | |
| 483 | + | <Badge tone="danger">Deleted</Badge> | |
| 484 | + | </TableCell> | |
| 485 | + | ) : ( | |
| 486 | + | <> | |
| 487 | + | <TableCell className="align-middle max-md:hidden"> | |
| 488 | + | <Updated row={row} base={base} /> | |
| 489 | + | </TableCell> | |
| 490 | + | <TableCell className="align-middle max-md:hidden"> | |
| 491 | + | {sha ? <CommitChecksBadge checks={checks} sha={sha} className="size-6" /> : null} | |
| 492 | + | </TableCell> | |
| 493 | + | <TableCell className="align-middle max-md:hidden"> | |
| 494 | + | {isDefault ? <span className="text-xs text-faint">Default</span> : <DriftBars drift={row.drift} scale={scale} main={main} />} | |
| 495 | + | </TableCell> | |
| 496 | + | <TableCell className="align-middle max-md:hidden"> | |
| 497 | + | <PullLink pull={row.pull} base={base} /> | |
| 498 | + | </TableCell> | |
| 499 | + | </> | |
| 500 | + | )} | |
| 501 | + | <TableCell className="w-px py-2 pr-2 align-middle whitespace-nowrap"> | |
| 502 | + | <RowActions row={row} context={context} /> | |
| 503 | + | </TableCell> | |
| 504 | + | </TableRow> | |
| 505 | + | ); | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | /** A section's branches as a table under the column names. */ | |
| 509 | + | export function BranchTable({ rows, context, label }: { rows: BranchRowData[]; context: BranchListContext; label: string }) { | |
| 510 | + | return ( | |
| 511 | + | <div className="overflow-hidden rounded-lg border border-line"> | |
| 512 | + | <Table aria-label={label}> | |
| 513 | + | <TableHeader> | |
| 514 | + | <TableRow> | |
| 515 | + | <TableHead>Branch</TableHead> | |
| 516 | + | <TableHead className="max-md:hidden">Updated</TableHead> | |
| 517 | + | <TableHead className="max-md:hidden">Check status</TableHead> | |
| 518 | + | <TableHead className="max-md:hidden"> | |
| 519 | + | <span className="inline-grid w-28 grid-cols-2"> | |
| 520 | + | <span className="border-r border-line pr-1.5 text-right">Behind</span> | |
| 521 | + | <span className="pl-1.5">Ahead</span> | |
| 522 | + | </span> | |
| 523 | + | </TableHead> | |
| 524 | + | <TableHead className="max-md:hidden">Pull request</TableHead> | |
| 525 | + | <TableHead className="w-px"> | |
| 526 | + | <span className="sr-only">Actions</span> | |
| 527 | + | </TableHead> | |
| 528 | + | </TableRow> | |
| 529 | + | </TableHeader> | |
| 530 | + | <TableBody> | |
| 531 | + | {rows.map((row) => ( | |
| 532 | + | <BranchRow key={row.name} row={row} context={context} /> | |
| 533 | + | ))} | |
| 534 | + | </TableBody> | |
| 535 | + | </Table> | |
| 536 | + | </div> | |
| 537 | + | ); | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | /** Making a branch: its name, and the branch, tag or commit it starts at. */ | |
| 541 | + | export function NewBranchDialog({ | |
| 542 | + | branches, | |
| 543 | + | main, | |
| 544 | + | onMade, | |
| 545 | + | }: { | |
| 546 | + | /** Every branch's name, the default first. */ | |
| 547 | + | branches: string[]; | |
| 548 | + | main: string; | |
| 549 | + | onMade: (name: string) => void; | |
| 550 | + | }) { | |
| 551 | + | const [open, setOpen] = useState(false); | |
| 552 | + | const [name, setName] = useState(""); | |
| 553 | + | const [from, setFrom] = useState(main); | |
| 554 | + | const [byCommit, setByCommit] = useState(false); | |
| 555 | + | const [commit, setCommit] = useState(""); | |
| 556 | + | const [error, setError] = useState<string | null>(null); | |
| 557 | + | const fetcher = useBranchAction((data) => { | |
| 558 | + | if (data.error) return setError(data.error); | |
| 559 | + | setOpen(false); | |
| 560 | + | onMade(data.branch ?? name.trim()); | |
| 561 | + | }); | |
| 562 | + | const busy = fetcher.state !== "idle"; | |
| 563 | + | const source = byCommit ? commit.trim() : from; | |
| 564 | + | return ( | |
| 565 | + | <Dialog | |
| 566 | + | open={open} | |
| 567 | + | onOpenChange={(now) => { | |
| 568 | + | if (now) { | |
| 569 | + | setName(""); | |
| 570 | + | setFrom(main); | |
| 571 | + | setByCommit(false); | |
| 572 | + | setCommit(""); | |
| 573 | + | setError(null); | |
| 574 | + | } | |
| 575 | + | setOpen(now); | |
| 576 | + | }} | |
| 577 | + | > | |
| 578 | + | <Button type="button" size="sm" onClick={() => setOpen(true)}> | |
| 579 | + | <GitBranchPlus size={15} /> | |
| 580 | + | New branch | |
| 581 | + | </Button> | |
| 582 | + | <DialogContent> | |
| 583 | + | <fetcher.Form method="post" className="grid gap-4"> | |
| 584 | + | <DialogHeader> | |
| 585 | + | <DialogTitle>Create a branch</DialogTitle> | |
| 586 | + | <DialogDescription>It starts at the same commit as the branch, tag or commit you pick. Push to it, or open a pull request from it.</DialogDescription> | |
| 587 | + | </DialogHeader> | |
| 588 | + | <input type="hidden" name="intent" value="create" /> | |
| 589 | + | <input type="hidden" name="from" value={source} /> | |
| 590 | + | <Field> | |
| 591 | + | <FieldLabel htmlFor="new-branch-name">Name</FieldLabel> | |
| 592 | + | <Input | |
| 593 | + | id="new-branch-name" | |
| 594 | + | name="branch" | |
| 595 | + | value={name} | |
| 596 | + | onChange={(event) => setName(event.target.value)} | |
| 597 | + | placeholder="feature/sign-in" | |
| 598 | + | maxLength={200} | |
| 599 | + | spellCheck={false} | |
| 600 | + | autoCapitalize="off" | |
| 601 | + | className="font-mono placeholder:font-sans" | |
| 602 | + | aria-invalid={error ? true : undefined} | |
| 603 | + | autoFocus | |
| 604 | + | /> | |
| 605 | + | {error && <FieldError>{error}</FieldError>} | |
| 606 | + | </Field> | |
| 607 | + | <Field> | |
| 608 | + | <FieldLabel htmlFor="new-branch-from">{byCommit ? "Tag or commit" : "Start from"}</FieldLabel> | |
| 609 | + | {byCommit ? ( | |
| 610 | + | <Input | |
| 611 | + | id="new-branch-from" | |
| 612 | + | value={commit} | |
| 613 | + | onChange={(event) => setCommit(event.target.value)} | |
| 614 | + | placeholder="v1.2.0 or 9f2c4e1" | |
| 615 | + | spellCheck={false} | |
| 616 | + | autoCapitalize="off" | |
| 617 | + | className="font-mono placeholder:font-sans" | |
| 618 | + | /> | |
| 619 | + | ) : ( | |
| 620 | + | <Combobox | |
| 621 | + | id="new-branch-from" | |
| 622 | + | aria-label="Branch to start from" | |
| 623 | + | value={from} | |
| 624 | + | onValueChange={setFrom} | |
| 625 | + | searchPlaceholder="Find a branch…" | |
| 626 | + | emptyText="No branch matches." | |
| 627 | + | className="font-mono" | |
| 628 | + | options={branches.map((branch) => ({ value: branch, label: branch, icon: <GitBranch /> }))} | |
| 629 | + | /> | |
| 630 | + | )} | |
| 631 | + | <FieldDescription> | |
| 632 | + | <Button type="button" variant="link" size="inline" className="text-xs" onClick={() => setByCommit(!byCommit)}> | |
| 633 | + | {byCommit ? "Start from a branch instead" : "Start from a tag or commit instead"} | |
| 634 | + | </Button> | |
| 635 | + | </FieldDescription> | |
| 636 | + | </Field> | |
| 637 | + | <DialogFooter> | |
| 638 | + | <Button type="button" variant="outline" onClick={() => setOpen(false)}> | |
| 639 | + | Cancel | |
| 640 | + | </Button> | |
| 641 | + | <Button type="submit" disabled={busy || name.trim() === "" || source === ""}> | |
| 642 | + | {busy && <LoaderCircle size={14} className="animate-spin" aria-hidden />} | |
| 643 | + | Create branch | |
| 644 | + | </Button> | |
| 645 | + | </DialogFooter> | |
| 646 | + | </fetcher.Form> | |
| 647 | + | </DialogContent> | |
| 648 | + | </Dialog> | |
| 649 | + | ); | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | // --- The default branch's protection hint, dismissed per person and repository --- | |
| 653 | + | ||
| 654 | + | const listeners = new Set<() => void>(); | |
| 655 | + | const subscribe = (listener: () => void) => { | |
| 656 | + | listeners.add(listener); | |
| 657 | + | return () => listeners.delete(listener); | |
| 658 | + | }; | |
| 659 | + | const storage = () => (typeof window === "undefined" ? null : window.localStorage); | |
| 660 | + | ||
| 661 | + | /** | |
| 662 | + | * That the default branch has no rules, for people who can add them, until | |
| 663 | + | * they dismiss it: remembered by this browser for them and this repository. | |
| 664 | + | */ | |
| 665 | + | export function ProtectCallout({ base, main, dismissKey }: { base: string; main: string; dismissKey: string }) { | |
| 666 | + | const hidden = useSyncExternalStore( | |
| 667 | + | subscribe, | |
| 668 | + | useCallback(() => { | |
| 669 | + | try { | |
| 670 | + | return storage()?.getItem(dismissKey) === "1"; | |
| 671 | + | } catch { | |
| 672 | + | return false; | |
| 673 | + | } | |
| 674 | + | }, [dismissKey]), | |
| 675 | + | () => false, | |
| 676 | + | ); | |
| 677 | + | if (hidden) return null; | |
| 678 | + | const dismiss = () => { | |
| 679 | + | try { | |
| 680 | + | storage()?.setItem(dismissKey, "1"); | |
| 681 | + | } catch { | |
| 682 | + | // Not remembered: it hides until the page is opened again. | |
| 683 | + | } | |
| 684 | + | for (const listener of listeners) listener(); | |
| 685 | + | }; | |
| 686 | + | return ( | |
| 687 | + | <Alert tone="warn" className="flex flex-wrap items-start gap-3 px-4 py-3"> | |
| 688 | + | <ShieldAlert size={16} className="mt-0.5 shrink-0 text-warn" /> | |
| 689 | + | <div className="min-w-0 grow basis-60"> | |
| 690 | + | <AlertTitle>{main} isn’t protected</AlertTitle> | |
| 691 | + | <AlertDescription> | |
| 692 | + | Anyone who can push can push straight to it or force-push over it. Add a ruleset to require pull requests and passing checks before anything lands on it. | |
| 693 | + | </AlertDescription> | |
| 694 | + | </div> | |
| 695 | + | <div className="flex shrink-0 items-center gap-2"> | |
| 696 | + | <Button asChild size="xs" variant="outline"> | |
| 697 | + | <Link to={`${base}/settings/rules/new`}>Protect this branch</Link> | |
| 698 | + | </Button> | |
| 699 | + | <Hint label="Dismiss"> | |
| 700 | + | <Button type="button" variant="ghost" size="icon-xs" aria-label="Dismiss" onClick={dismiss}> | |
| 701 | + | <X size={14} /> | |
| 702 | + | </Button> | |
| 703 | + | </Hint> | |
| 704 | + | </div> | |
| 705 | + | </Alert> | |
| 706 | + | ); | |
| 707 | + | } |
| 19 | 19 | ||
| 20 | 20 | export type ActiveBranch = { | |
| 21 | 21 | name: string; | |
| 22 | + | /** The commit it points to. */ | |
| 23 | + | head: string; | |
| 22 | 24 | commit: { hash: string; message: string; author: CommitPerson; coAuthors: CommitPerson[]; at: string } | null; | |
| 23 | 25 | /** Null when the two histories were not read far enough to meet. */ | |
| 24 | 26 | drift: Drift | null; | |
| 25 | − | pull: { number: number; title: string; checkStatus: Pull["checkStatus"]; draft: boolean } | null; | |
| 27 | + | /** The open pull request from it, else the newest merged or closed one. */ | |
| 28 | + | pull: { number: number; title: string; checkStatus: Pull["checkStatus"]; draft: boolean; status: Pull["status"] } | null; | |
| 26 | 29 | preview: string | null; | |
| 27 | 30 | }; | |
| 28 | 31 |
| 1 | + | import { ImageUp, LoaderCircle, Trash2 } from "lucide-react"; | |
| 2 | + | import { useRef } from "react"; | |
| 3 | + | import { useFetcher } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { ProductionImage } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import { PRODUCTION_IMAGE_ACCEPT } from "../lib/production-image"; | |
| 8 | + | import { ErrorText, SubmitButton, usePending } from "./ui"; | |
| 9 | + | import { Button } from "./ui/button"; | |
| 10 | + | import { Card } from "./ui/card"; | |
| 11 | + | import { SwitchCard } from "./ui/switch"; | |
| 12 | + | ||
| 13 | + | /** | |
| 14 | + | * A project's production image, in its settings: the one shown now, a | |
| 15 | + | * button to pick a new one (sent as soon as it is picked), a way to remove | |
| 16 | + | * it, and whether it is shown instead of a screenshot. Posts | |
| 17 | + | * `intent=production-image` with the file in `image`, | |
| 18 | + | * `intent=remove-production-image`, or `intent=prefer-production-image` | |
| 19 | + | * with `preferred`. | |
| 20 | + | */ | |
| 21 | + | export function ProductionImageField({ | |
| 22 | + | image, | |
| 23 | + | src, | |
| 24 | + | screenshots, | |
| 25 | + | }: { | |
| 26 | + | image: ProductionImage | null; | |
| 27 | + | /** Where the image is served, when there is one. */ | |
| 28 | + | src: string | null; | |
| 29 | + | /** Whether production gets a screenshot by itself: on g1t.page, or at an address elsewhere. */ | |
| 30 | + | screenshots: boolean; | |
| 31 | + | }) { | |
| 32 | + | const upload = useFetcher<{ error?: string }>(); | |
| 33 | + | const prefer = useFetcher<{ error?: string }>(); | |
| 34 | + | const form = useRef<HTMLFormElement>(null); | |
| 35 | + | const uploading = usePending({ intent: "production-image" }, upload); | |
| 36 | + | const removing = usePending({ intent: "remove-production-image" }, upload); | |
| 37 | + | const busy = uploading || removing; | |
| 38 | + | // Shown as asked while it is being saved. | |
| 39 | + | const preferred = prefer.formData ? prefer.formData.get("preferred") === "on" : (image?.preferred ?? false); | |
| 40 | + | return ( | |
| 41 | + | <Card className="p-4"> | |
| 42 | + | <div className="flex flex-col gap-4 sm:flex-row sm:items-start"> | |
| 43 | + | <div className="aspect-16/10 w-full shrink-0 overflow-hidden rounded-lg border border-line bg-bg sm:w-48"> | |
| 44 | + | {src ? ( | |
| 45 | + | <img src={src} alt="The uploaded image of production" width={1280} height={800} className="size-full object-cover object-top" /> | |
| 46 | + | ) : ( | |
| 47 | + | <div className="flex size-full items-center justify-center text-faint"> | |
| 48 | + | <ImageUp size={20} aria-hidden="true" /> | |
| 49 | + | </div> | |
| 50 | + | )} | |
| 51 | + | </div> | |
| 52 | + | <div className="min-w-0 grow space-y-2"> | |
| 53 | + | <p className="text-sm text-muted"> | |
| 54 | + | {screenshots | |
| 55 | + | ? "Shown on the overview while there is no screenshot of production, or instead of one." | |
| 56 | + | : "Shown on the overview's production card. Give production's address, and a screenshot of it is taken by itself."} | |
| 57 | + | </p> | |
| 58 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 59 | + | <upload.Form method="post" encType="multipart/form-data" ref={form}> | |
| 60 | + | <input type="hidden" name="intent" value="production-image" /> | |
| 61 | + | <Button asChild variant="outline" className={busy ? "pointer-events-none opacity-50" : "cursor-pointer"}> | |
| 62 | + | <label> | |
| 63 | + | {uploading ? <LoaderCircle size={15} aria-hidden="true" className="animate-spin" /> : <ImageUp size={15} />} | |
| 64 | + | {uploading ? "Uploading…" : image ? "Upload a new image" : "Upload an image"} | |
| 65 | + | <input | |
| 66 | + | type="file" | |
| 67 | + | name="image" | |
| 68 | + | accept={PRODUCTION_IMAGE_ACCEPT} | |
| 69 | + | className="sr-only" | |
| 70 | + | onChange={(event) => { | |
| 71 | + | if (event.currentTarget.files?.length) form.current?.requestSubmit(); | |
| 72 | + | }} | |
| 73 | + | /> | |
| 74 | + | </label> | |
| 75 | + | </Button> | |
| 76 | + | </upload.Form> | |
| 77 | + | {image && ( | |
| 78 | + | <upload.Form method="post"> | |
| 79 | + | <input type="hidden" name="intent" value="remove-production-image" /> | |
| 80 | + | <SubmitButton fetcher={upload} variant="outline" pending="Removing…" match={{ intent: "remove-production-image" }} disabled={uploading}> | |
| 81 | + | <Trash2 size={15} /> | |
| 82 | + | Remove | |
| 83 | + | </SubmitButton> | |
| 84 | + | </upload.Form> | |
| 85 | + | )} | |
| 86 | + | </div> | |
| 87 | + | <p className="text-xs text-faint">PNG, JPEG or WebP, at most 5 MB. Wide images fit best, such as 1280 × 800.</p> | |
| 88 | + | <ErrorText>{upload.data?.error ?? prefer.data?.error}</ErrorText> | |
| 89 | + | </div> | |
| 90 | + | </div> | |
| 91 | + | {image && screenshots && ( | |
| 92 | + | <SwitchCard | |
| 93 | + | className="mt-4" | |
| 94 | + | title="Use the uploaded image instead of a screenshot" | |
| 95 | + | checked={preferred} | |
| 96 | + | onCheckedChange={(on) => prefer.submit({ intent: "prefer-production-image", preferred: on ? "on" : "" }, { method: "post" })} | |
| 97 | + | > | |
| 98 | + | No screenshot of production is taken while it is on. | |
| 99 | + | </SwitchCard> | |
| 100 | + | )} | |
| 101 | + | </Card> | |
| 102 | + | ); | |
| 103 | + | } |
| 1 | − | import { Globe } from "lucide-react"; | |
| 1 | + | import { Globe, ImagePlus } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useRef, useState } from "react"; | |
| 3 | 3 | ||
| 4 | 4 | import { cn } from "../lib/cn"; | |
| 5 | + | import type { CardSource } from "../lib/production-image"; | |
| 5 | 6 | import { DeployLink } from "./deploy"; | |
| 7 | + | import { ButtonLink } from "./ui"; | |
| 6 | 8 | ||
| 7 | 9 | /** | |
| 8 | − | * A thumbnail of production as it is now, linking to it. The screenshot is | |
| 9 | − | * taken once per deploy; until it has loaded, or when there is none yet, a | |
| 10 | − | * quiet frame with the address stands in. | |
| 10 | + | * A thumbnail of production, linking to it: a screenshot (taken once per | |
| 11 | + | * deploy on g1t.page, or of the address of production deployed | |
| 12 | + | * elsewhere), else an image someone uploaded (lib/production-image.ts | |
| 13 | + | * `cardSources`). Each source is tried in turn, the next when one does not | |
| 14 | + | * load. Until one has loaded, or when there is none, a quiet frame with | |
| 15 | + | * the address stands in; to someone who may change the project's | |
| 16 | + | * settings it offers to add an image. | |
| 11 | 17 | */ | |
| 12 | 18 | export function ProductionShot({ | |
| 13 | − | src, | |
| 19 | + | sources, | |
| 14 | 20 | href, | |
| 15 | 21 | label, | |
| 22 | + | note, | |
| 23 | + | addImage, | |
| 16 | 24 | className, | |
| 17 | 25 | }: { | |
| 18 | − | /** Null when nothing is deployed yet. */ | |
| 19 | − | src: string | null; | |
| 26 | + | /** The images to try, in order; empty for none. */ | |
| 27 | + | sources: CardSource[]; | |
| 20 | 28 | href: string | null; | |
| 21 | 29 | label: string; | |
| 30 | + | /** What the empty frame says, such as when a screenshot comes; none for a quiet frame. */ | |
| 31 | + | note?: string | null; | |
| 32 | + | /** Where an image is added, for someone who may; null for anyone else. */ | |
| 33 | + | addImage?: string | null; | |
| 22 | 34 | className?: string; | |
| 23 | 35 | }) { | |
| 24 | 36 | const image = useRef<HTMLImageElement>(null); | |
| 25 | − | const [state, setState] = useState<"loading" | "loaded" | "failed">("loading"); | |
| 37 | + | const [at, setAt] = useState(0); | |
| 38 | + | const [loaded, setLoaded] = useState(false); | |
| 39 | + | const key = sources.map((source) => source.src).join("\n"); | |
| 40 | + | const source = sources[at] ?? null; | |
| 41 | + | ||
| 42 | + | // Other sources (another deploy, a new upload): from the first again. | |
| 43 | + | useEffect(() => { | |
| 44 | + | setAt(0); | |
| 45 | + | setLoaded(false); | |
| 46 | + | }, [key]); | |
| 26 | 47 | ||
| 27 | 48 | // An image that settled before hydration fired its events unheard. | |
| 28 | 49 | useEffect(() => { | |
| 29 | 50 | const img = image.current; | |
| 30 | 51 | if (!img?.complete) return; | |
| 31 | − | setState(img.naturalWidth > 0 ? "loaded" : "failed"); | |
| 32 | − | }, [src]); | |
| 52 | + | if (img.naturalWidth > 0) setLoaded(true); | |
| 53 | + | else setAt((index) => index + 1); | |
| 54 | + | }, [source?.src]); | |
| 55 | + | ||
| 56 | + | const empty = source == null; | |
| 57 | + | const waiting = !empty && !loaded ? (source.kind === "screenshot" ? "Taking a screenshot…" : "Loading the image…") : null; | |
| 33 | 58 | ||
| 34 | 59 | const frame = ( | |
| 35 | 60 | <span | |
| 36 | 61 | className={cn( | |
| 37 | − | "relative block aspect-[16/10] w-full overflow-hidden rounded-lg border border-line bg-bg", | |
| 62 | + | "relative block aspect-16/10 w-full overflow-hidden rounded-lg border border-line bg-bg", | |
| 38 | 63 | href && "transition-colors group-hover:border-line-strong", | |
| 39 | 64 | )} | |
| 40 | 65 | > | |
| 41 | − | {state !== "loaded" && <Placeholder label={label} waiting={src != null && state === "loading"} />} | |
| 42 | − | {src && state !== "failed" && ( | |
| 66 | + | {!loaded && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />} | |
| 67 | + | {source && ( | |
| 43 | 68 | <img | |
| 69 | + | key={source.src} | |
| 44 | 70 | ref={image} | |
| 45 | − | src={src} | |
| 46 | − | alt={`Screenshot of ${label}`} | |
| 71 | + | src={source.src} | |
| 72 | + | alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`} | |
| 47 | 73 | width={1280} | |
| 48 | 74 | height={800} | |
| 49 | 75 | decoding="async" | |
| 50 | − | onLoad={() => setState("loaded")} | |
| 51 | − | onError={() => setState("failed")} | |
| 76 | + | onLoad={() => setLoaded(true)} | |
| 77 | + | onError={() => { | |
| 78 | + | setLoaded(false); | |
| 79 | + | setAt((index) => index + 1); | |
| 80 | + | }} | |
| 52 | 81 | className={cn( | |
| 53 | 82 | "absolute inset-0 size-full object-cover object-top transition-opacity duration-300", | |
| 54 | − | state === "loaded" ? "opacity-100" : "opacity-0", | |
| 83 | + | loaded ? "opacity-100" : "opacity-0", | |
| 55 | 84 | )} | |
| 56 | 85 | /> | |
| 57 | 86 | )} | |
| 58 | 87 | </span> | |
| 59 | 88 | ); | |
| 60 | 89 | ||
| 61 | − | return href ? ( | |
| 62 | − | <DeployLink href={href} className={cn("group block", className)} aria-label={`Visit ${label}`}> | |
| 63 | − | {frame} | |
| 64 | − | </DeployLink> | |
| 65 | − | ) : ( | |
| 66 | − | <div className={className}>{frame}</div> | |
| 90 | + | return ( | |
| 91 | + | <div className={cn("relative", className)}> | |
| 92 | + | {href ? ( | |
| 93 | + | <DeployLink href={href} className="group block" aria-label={`Visit ${label}`}> | |
| 94 | + | {frame} | |
| 95 | + | </DeployLink> | |
| 96 | + | ) : ( | |
| 97 | + | frame | |
| 98 | + | )} | |
| 99 | + | {empty && addImage && ( | |
| 100 | + | // Beside the link, not in it: a link holds no other link. | |
| 101 | + | <span className="pointer-events-none absolute inset-x-0 top-7 bottom-0 flex flex-col items-center justify-center gap-2 px-3 text-center"> | |
| 102 | + | <ButtonLink to={addImage} variant="outline" size="xs" className="pointer-events-auto bg-surface"> | |
| 103 | + | <ImagePlus size={13} /> | |
| 104 | + | Add an image of your app | |
| 105 | + | </ButtonLink> | |
| 106 | + | {note && <span className="text-[0.6875rem] text-faint">{note}</span>} | |
| 107 | + | </span> | |
| 108 | + | )} | |
| 109 | + | </div> | |
| 67 | 110 | ); | |
| 68 | 111 | } | |
| 69 | 112 | ||
| 70 | − | function Placeholder({ label, waiting }: { label: string; waiting: boolean }) { | |
| 113 | + | /** The frame without a picture: a browser's bar with the address, and faint lines of a page. */ | |
| 114 | + | function Placeholder({ label, waiting, note }: { label: string; waiting: string | null; note: string | null }) { | |
| 115 | + | const line = waiting ?? note; | |
| 71 | 116 | return ( | |
| 72 | 117 | <span className="absolute inset-0 flex flex-col"> | |
| 73 | 118 | <span className="flex items-center gap-1.5 border-b border-line bg-surface px-2.5 py-1.5"> | |
| 74 | 119 | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 75 | 120 | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 76 | 121 | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 77 | − | <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span> | |
| 122 | + | {label && <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span>} | |
| 78 | 123 | </span> | |
| 79 | 124 | <span className="relative flex grow flex-col gap-2 p-3.5"> | |
| 80 | 125 | <span className="h-2 w-2/5 rounded-full bg-line/80" /> | |
| 81 | 126 | <span className="h-1.5 w-4/5 rounded-full bg-line/50" /> | |
| 82 | 127 | <span className="h-1.5 w-3/5 rounded-full bg-line/50" /> | |
| 83 | − | <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint"> | |
| 84 | − | <Globe size={12} className={waiting ? "animate-pulse" : undefined} /> | |
| 85 | − | {waiting ? "Taking a screenshot…" : "A screenshot appears after the next deploy"} | |
| 86 | − | </span> | |
| 128 | + | {line && ( | |
| 129 | + | <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint"> | |
| 130 | + | <Globe size={12} className={waiting ? "animate-pulse" : undefined} /> | |
| 131 | + | {line} | |
| 132 | + | </span> | |
| 133 | + | )} | |
| 87 | 134 | </span> | |
| 88 | 135 | </span> | |
| 89 | 136 | ); |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…