Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
7 commits
- Projects: a library publishing a Maven, NuGet or RubyGems package says soChase Piercea11c093
- Merge branch 'worktree-agent-aca4790ea223aa235'Chase Pierce8e8305b
- Docs: the workspace's page and tabs, the Projects tab, the sidebar, pinsChase Pierceb1f4635
- API: pinned projects over REST and MCPChase Piercecbaedf5
- Navigation: the workspace's own page with tabs, pins and recent in the sidebarChase Pierce7e6396c
- Projects: when each was last pushed, and how active it is latelyChase Pierce912cf34
- Projects: pinned and recent projects, per person per workspaceChase Piercea85ec6b
| 12 | 12 | mod notifications; | |
| 13 | 13 | mod oauth; | |
| 14 | 14 | mod openapi; | |
| 15 | + | mod pins; | |
| 15 | 16 | mod operations; | |
| 16 | 17 | mod renamed; | |
| 17 | 18 | #[cfg(test)] |
| 39 | 39 | ], | |
| 40 | 40 | ), | |
| 41 | 41 | ( | |
| 42 | + | "Pinned projects", | |
| 43 | + | "The projects you keep at the top of a workspace's sidebar, in your order, up to eight a workspace. Your own: personal tokens and sessions only.", | |
| 44 | + | &[Op::ListPinnedProjects, Op::PinProject, Op::UnpinProject, Op::ReorderPinnedProjects], | |
| 45 | + | ), | |
| 46 | + | ( | |
| 42 | 47 | "Workspaces", | |
| 43 | 48 | "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.", | |
| 44 | 49 | &[Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace], | |
| 373 | 378 | Op::SetRepoSubscription => "Watch a repository", | |
| 374 | 379 | Op::DeleteRepoSubscription => "Stop watching a repository", | |
| 375 | 380 | Op::ListWatchedRepos => "List repositories you watch", | |
| 381 | + | Op::ListPinnedProjects => "List your pinned projects", | |
| 382 | + | Op::PinProject => "Pin a project", | |
| 383 | + | Op::UnpinProject => "Unpin a project", | |
| 384 | + | Op::ReorderPinnedProjects => "Reorder your pinned projects", | |
| 376 | 385 | } | |
| 377 | 386 | } | |
| 378 | 387 |
| 44 | 44 | pub search: Fetcher, | |
| 45 | 45 | /// Secret and dependency alerts. | |
| 46 | 46 | pub security: Fetcher, | |
| 47 | + | /// Projects: a person's pinned ones. | |
| 48 | + | pub projects: Fetcher, | |
| 47 | 49 | /// Where the request came in, for its audit entries. | |
| 48 | 50 | pub audit: crate::audit::AuditContext, | |
| 49 | 51 | /// Set for a request made with an agent's token: all it may do. | |
| 67 | 69 | context: env.service("CONTEXT")?, | |
| 68 | 70 | search: env.service("SEARCH")?, | |
| 69 | 71 | security: env.service("SECURITY")?, | |
| 72 | + | projects: env.service("PROJECTS")?, | |
| 70 | 73 | scope: None, | |
| 71 | 74 | audit: crate::audit::AuditContext::default(), | |
| 72 | 75 | addresses: crate::addresses::Addresses::from_env(env), | |
| 207 | 210 | SetRepoSubscription, | |
| 208 | 211 | DeleteRepoSubscription, | |
| 209 | 212 | ListWatchedRepos, | |
| 213 | + | ListPinnedProjects, | |
| 214 | + | PinProject, | |
| 215 | + | UnpinProject, | |
| 216 | + | ReorderPinnedProjects, | |
| 210 | 217 | } | |
| 211 | 218 | ||
| 212 | 219 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 454 | 461 | } | |
| 455 | 462 | ||
| 456 | 463 | impl Op { | |
| 457 | − | pub const ALL: [Op; 131] = [ | |
| 464 | + | pub const ALL: [Op; 135] = [ | |
| 458 | 465 | Op::Whoami, | |
| 459 | 466 | Op::CreateWorkspace, | |
| 460 | 467 | Op::DeleteWorkspace, | |
| 586 | 593 | Op::SetRepoSubscription, | |
| 587 | 594 | Op::DeleteRepoSubscription, | |
| 588 | 595 | Op::ListWatchedRepos, | |
| 596 | + | Op::ListPinnedProjects, | |
| 597 | + | Op::PinProject, | |
| 598 | + | Op::UnpinProject, | |
| 599 | + | Op::ReorderPinnedProjects, | |
| 589 | 600 | ]; | |
| 590 | 601 | ||
| 591 | 602 | pub fn by_name(name: &str) -> Option<Op> { | |
| 726 | 737 | Op::SetRepoSubscription => "set_repo_subscription", | |
| 727 | 738 | Op::DeleteRepoSubscription => "delete_repo_subscription", | |
| 728 | 739 | Op::ListWatchedRepos => "list_watched_repos", | |
| 740 | + | Op::ListPinnedProjects => "list_pinned_projects", | |
| 741 | + | Op::PinProject => "pin_project", | |
| 742 | + | Op::UnpinProject => "unpin_project", | |
| 743 | + | Op::ReorderPinnedProjects => "reorder_pinned_projects", | |
| 729 | 744 | } | |
| 730 | 745 | } | |
| 731 | 746 | ||
| 1090 | 1105 | Op::ListWatchedRepos => { | |
| 1091 | 1106 | "The repositories you watch other than the default way: all activity, custom or ignored, each with its `level` and `events`." | |
| 1092 | 1107 | } | |
| 1108 | + | Op::ListPinnedProjects => { | |
| 1109 | + | "Your pinned projects in a workspace, in your order (`position` 0 first): the ones its sidebar keeps at the top for you. Projects you can no longer see are left out. Your own: a personal access token or a session." | |
| 1110 | + | } | |
| 1111 | + | Op::PinProject => { | |
| 1112 | + | "Pin a project you can see, at `position` (0 first) or at the end; pinning one already pinned moves it. At most 8 a workspace: unpin one first when you have 8. Returns your pins, in order." | |
| 1113 | + | } | |
| 1114 | + | Op::UnpinProject => { | |
| 1115 | + | "Unpin a project. Unpinning one that is not pinned changes nothing. Returns your pins, in order." | |
| 1116 | + | } | |
| 1117 | + | Op::ReorderPinnedProjects => { | |
| 1118 | + | "Put your pins in a workspace in a new order: `projects` names every pinned project's slug, once, in the order you want them. Returns your pins, in order." | |
| 1119 | + | } | |
| 1093 | 1120 | } | |
| 1094 | 1121 | } | |
| 1095 | 1122 | ||
| 2080 | 2107 | &["repo"], | |
| 2081 | 2108 | ), | |
| 2082 | 2109 | Op::ListWatchedRepos => object(json!({}), &[]), | |
| 2110 | + | Op::ListPinnedProjects => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 2111 | + | Op::PinProject => object( | |
| 2112 | + | json!({ | |
| 2113 | + | "workspace": workspace_schema(), | |
| 2114 | + | "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." }, | |
| 2115 | + | "position": { "type": "integer", "description": "Where it goes, 0 first. Left out: at the end." }, | |
| 2116 | + | }), | |
| 2117 | + | &["workspace", "project"], | |
| 2118 | + | ), | |
| 2119 | + | Op::UnpinProject => object( | |
| 2120 | + | json!({ | |
| 2121 | + | "workspace": workspace_schema(), | |
| 2122 | + | "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." }, | |
| 2123 | + | }), | |
| 2124 | + | &["workspace", "project"], | |
| 2125 | + | ), | |
| 2126 | + | Op::ReorderPinnedProjects => object( | |
| 2127 | + | json!({ | |
| 2128 | + | "workspace": workspace_schema(), | |
| 2129 | + | "projects": { | |
| 2130 | + | "type": "array", | |
| 2131 | + | "items": { "type": "string" }, | |
| 2132 | + | "description": "Every pinned project's slug, once, in the order you want them.", | |
| 2133 | + | }, | |
| 2134 | + | }), | |
| 2135 | + | &["workspace", "projects"], | |
| 2136 | + | ), | |
| 2083 | 2137 | } | |
| 2084 | 2138 | } | |
| 2085 | 2139 | ||
| 2177 | 2231 | | Op::SetThreadSubscription | |
| 2178 | 2232 | | Op::DeleteThreadSubscription | |
| 2179 | 2233 | | Op::ListWatchedRepos | |
| 2234 | + | | Op::ListPinnedProjects | |
| 2235 | + | | Op::PinProject | |
| 2236 | + | | Op::UnpinProject | |
| 2237 | + | | Op::ReorderPinnedProjects | |
| 2180 | 2238 | ) | |
| 2181 | 2239 | } | |
| 2182 | 2240 | ||
| 2183 | − | /// Whether the operation is about the caller's own inbox: notifications, | |
| 2184 | − | /// subscriptions and watching. Nobody else's business, so not audited. | |
| 2241 | + | /// Whether the operation is about the caller's own inbox (notifications, | |
| 2242 | + | /// subscriptions and watching) or their pins. Nobody else's business, | |
| 2243 | + | /// so not audited. | |
| 2185 | 2244 | pub(crate) fn personal(self) -> bool { | |
| 2186 | 2245 | matches!( | |
| 2187 | 2246 | self, | |
| 2199 | 2258 | | Op::SetRepoSubscription | |
| 2200 | 2259 | | Op::DeleteRepoSubscription | |
| 2201 | 2260 | | Op::ListWatchedRepos | |
| 2261 | + | | Op::ListPinnedProjects | |
| 2262 | + | | Op::PinProject | |
| 2263 | + | | Op::UnpinProject | |
| 2264 | + | | Op::ReorderPinnedProjects | |
| 2202 | 2265 | ) | |
| 2203 | 2266 | } | |
| 2204 | 2267 | ||
| 3617 | 3680 | | Op::SetRepoSubscription | |
| 3618 | 3681 | | Op::DeleteRepoSubscription | |
| 3619 | 3682 | | Op::ListWatchedRepos => crate::notifications::run(self, services, viewer, input).await, | |
| 3683 | + | // A person's pinned projects: the projects service keeps them. | |
| 3684 | + | Op::ListPinnedProjects | Op::PinProject | Op::UnpinProject | Op::ReorderPinnedProjects => { | |
| 3685 | + | crate::pins::run(self, services, viewer, input).await | |
| 3686 | + | } | |
| 3620 | 3687 | Op::ReopenSecurityAlert => { | |
| 3621 | 3688 | let changed: Outcome<AlertChange> = call( | |
| 3622 | 3689 | &services.security, |
| 1 | + | //! Pinned projects: the projects a person keeps at the top of a | |
| 2 | + | //! workspace's sidebar, in their order, up to eight a workspace. The | |
| 3 | + | //! projects service keeps them; this is their public shape, in snake_case. | |
| 4 | + | //! | |
| 5 | + | //! A person's own: a personal access token or a session, never a | |
| 6 | + | //! workspace's token or g1t's agents. | |
| 7 | + | ||
| 8 | + | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Viewer}; | |
| 9 | + | use serde_json::{Value, json}; | |
| 10 | + | use worker::Result; | |
| 11 | + | ||
| 12 | + | use crate::operations::{Op, Services}; | |
| 13 | + | ||
| 14 | + | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 15 | + | Ok(Outcome::fail(code, message)) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | fn text(input: &Value, key: &str) -> Option<String> { | |
| 19 | + | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_lowercase) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /// A place in the pins, 0 first, given as a number or as digits. | |
| 23 | + | fn position(input: &Value) -> Option<u32> { | |
| 24 | + | match &input["position"] { | |
| 25 | + | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), | |
| 26 | + | Value::String(digits) => digits.trim().parse().ok(), | |
| 27 | + | _ => None, | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// The slugs `projects` names, in order: strings, or objects with a `slug`. | |
| 32 | + | pub(crate) fn slugs(input: &Value) -> Option<Vec<String>> { | |
| 33 | + | input["projects"].as_array().map(|items| { | |
| 34 | + | items | |
| 35 | + | .iter() | |
| 36 | + | .filter_map(|item| item.as_str().or_else(|| item["slug"].as_str())) | |
| 37 | + | .map(|slug| slug.trim().to_lowercase()) | |
| 38 | + | .filter(|slug| !slug.is_empty()) | |
| 39 | + | .collect() | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /// A project as the projects service answers (camelCase), as the API shows | |
| 44 | + | /// a pin: its place, what it is, and where it is. | |
| 45 | + | pub(crate) fn pin_json(project: &Value, position: usize, site: &str) -> Value { | |
| 46 | + | let workspace = project["workspace"].as_str().unwrap_or_default(); | |
| 47 | + | let slug = project["slug"].as_str().unwrap_or_default(); | |
| 48 | + | json!({ | |
| 49 | + | "position": position, | |
| 50 | + | "id": project["id"], | |
| 51 | + | "workspace": workspace, | |
| 52 | + | "slug": slug, | |
| 53 | + | "name": project["name"], | |
| 54 | + | "description": project["description"], | |
| 55 | + | "private": project["private"], | |
| 56 | + | "archived": project["archived"], | |
| 57 | + | "kind": project["kind"], | |
| 58 | + | "url": format!("{}/{workspace}/{slug}", site.trim_end_matches('/')), | |
| 59 | + | "pushed_at": project["pushedAt"], | |
| 60 | + | "updated_at": project["updatedAt"], | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fn pins_json(projects: &[Value], site: &str) -> Value { | |
| 65 | + | Value::Array(projects.iter().enumerate().map(|(at, project)| pin_json(project, at, site)).collect()) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// Runs one of the pin operations. | |
| 69 | + | pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 70 | + | let user = match viewer { | |
| 71 | + | Some(user) if user.kind == PrincipalKind::User => user, | |
| 72 | + | Some(_) => { | |
| 73 | + | return failed( | |
| 74 | + | FailureCode::Forbidden, | |
| 75 | + | "Pins are a person's own: use a personal access token, not a workspace's or an agent's.", | |
| 76 | + | ); | |
| 77 | + | } | |
| 78 | + | None => return failed(FailureCode::Unauthenticated, "This needs a g1t access token."), | |
| 79 | + | }; | |
| 80 | + | let Some(workspace) = text(input, "workspace") else { | |
| 81 | + | return failed(FailureCode::Invalid, "Give the workspace's slug."); | |
| 82 | + | }; | |
| 83 | + | let site = services.addresses.site.as_str(); | |
| 84 | + | let changed = |outcome: Outcome<Vec<Value>>| -> Result<Outcome<Value>> { | |
| 85 | + | Ok(match outcome { | |
| 86 | + | Outcome::Ok(projects) => Outcome::Ok(pins_json(&projects, site)), | |
| 87 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 88 | + | }) | |
| 89 | + | }; | |
| 90 | + | match op { | |
| 91 | + | Op::ListPinnedProjects => { | |
| 92 | + | let shortcuts: Value = g1t_kit::call( | |
| 93 | + | &services.projects, | |
| 94 | + | "shortcuts", | |
| 95 | + | &json!({ "workspace": workspace, "viewer": viewer }), | |
| 96 | + | ) | |
| 97 | + | .await?; | |
| 98 | + | let pinned = shortcuts["pinned"].as_array().cloned().unwrap_or_default(); | |
| 99 | + | Ok(Outcome::Ok(pins_json(&pinned, site))) | |
| 100 | + | } | |
| 101 | + | Op::PinProject | Op::UnpinProject => { | |
| 102 | + | let Some(slug) = text(input, "project") else { | |
| 103 | + | return failed(FailureCode::Invalid, "Give the project's slug."); | |
| 104 | + | }; | |
| 105 | + | let (method, args) = if op == Op::PinProject { | |
| 106 | + | ("pin", json!({ "actor": user, "workspace": workspace, "slug": slug, "position": position(input) })) | |
| 107 | + | } else { | |
| 108 | + | ("unpin", json!({ "actor": user, "workspace": workspace, "slug": slug })) | |
| 109 | + | }; | |
| 110 | + | changed(g1t_kit::call(&services.projects, method, &args).await?) | |
| 111 | + | } | |
| 112 | + | Op::ReorderPinnedProjects => { | |
| 113 | + | let Some(order) = slugs(input) else { | |
| 114 | + | return failed(FailureCode::Invalid, "Give projects: every pinned project's slug, in the order you want them."); | |
| 115 | + | }; | |
| 116 | + | changed( | |
| 117 | + | g1t_kit::call( | |
| 118 | + | &services.projects, | |
| 119 | + | "reorder_pins", | |
| 120 | + | &json!({ "actor": user, "workspace": workspace, "slugs": order }), | |
| 121 | + | ) | |
| 122 | + | .await?, | |
| 123 | + | ) | |
| 124 | + | } | |
| 125 | + | _ => failed(FailureCode::Invalid, "Not a pin operation."), | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[cfg(test)] | |
| 130 | + | mod tests { | |
| 131 | + | use super::*; | |
| 132 | + | ||
| 133 | + | #[test] | |
| 134 | + | fn a_pin_is_snake_case_with_its_place_and_address() { | |
| 135 | + | let project = json!({ | |
| 136 | + | "id": "prj_1", "workspace": "acme", "slug": "web", "name": "Web", "description": null, | |
| 137 | + | "private": true, "archived": false, "kind": "app", "pushedAt": "2026-10-07T10:00:00.000Z", | |
| 138 | + | "updatedAt": "2026-10-01T10:00:00.000Z", "descriptionInherited": false, | |
| 139 | + | }); | |
| 140 | + | let pin = pin_json(&project, 2, "https://g1t.sh/"); | |
| 141 | + | assert_eq!(pin["position"], 2); | |
| 142 | + | assert_eq!(pin["url"], "https://g1t.sh/acme/web"); | |
| 143 | + | assert_eq!(pin["pushed_at"], "2026-10-07T10:00:00.000Z"); | |
| 144 | + | assert!(pin.get("pushedAt").is_none()); | |
| 145 | + | assert!(pin.get("descriptionInherited").is_none()); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn the_order_is_read_from_slugs_or_objects() { | |
| 150 | + | assert_eq!(slugs(&json!({ "projects": ["Web", { "slug": "api" }, ""] })), Some(vec!["web".into(), "api".into()])); | |
| 151 | + | assert_eq!(slugs(&json!({})), None); | |
| 152 | + | } | |
| 153 | + | } |
| 43 | 43 | ], | |
| 44 | 44 | "token": { | |
| 45 | 45 | "token_id": "tok_01kkntd3p2v8x6ym5r0c1q7a9e", | |
| 46 | − | "scopes": ["repo:read", "issues:read", "issues:write", "pull_requests:read", "pull_requests:write"] | |
| 46 | + | "scopes": [ | |
| 47 | + | "repo:read", | |
| 48 | + | "issues:read", | |
| 49 | + | "issues:write", | |
| 50 | + | "pull_requests:read", | |
| 51 | + | "pull_requests:write" | |
| 52 | + | ] | |
| 47 | 53 | } | |
| 48 | 54 | }, | |
| 49 | 55 | "notes": "`token` is what the access token you called with may do: its `scopes` (left out for full access) and `legacy` when it was made before tokens had scopes. A token reaches every workspace and repository whoever it acts as can; its scopes say what it may do there. See [Scopes](/guides/authentication/#scopes). `kind` is `user`, `workspace` for a [workspace access token](/guides/workspaces/#workspace-access-tokens), or `agent` for the token a g1t agent works with. For a workspace token, `username` is the workspace's slug and `workspaces` holds only that workspace. Each workspace carries its `base_permission`: what a member gets on each of its repositories (owners have Admin). Roles given on single repositories are in `grants`, each with `repo_id`, `workspace` and `role`; it is left out when there are none. See [Access and roles](/guides/access-and-roles/)." | |
| 4977 | 4983 | "updated_at": "2026-10-05T14:30:00.000Z" | |
| 4978 | 4984 | } | |
| 4979 | 4985 | ] | |
| 4986 | + | }, | |
| 4987 | + | "list_pinned_projects": { | |
| 4988 | + | "params": { | |
| 4989 | + | "workspace": "flagon-io" | |
| 4990 | + | }, | |
| 4991 | + | "response": [ | |
| 4992 | + | { | |
| 4993 | + | "position": 0, | |
| 4994 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 4995 | + | "workspace": "flagon-io", | |
| 4996 | + | "slug": "g1t", | |
| 4997 | + | "name": "g1t", | |
| 4998 | + | "description": null, | |
| 4999 | + | "private": false, | |
| 5000 | + | "archived": false, | |
| 5001 | + | "kind": "app", | |
| 5002 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 5003 | + | "pushed_at": "2026-10-07T10:00:00.000Z", | |
| 5004 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5005 | + | }, | |
| 5006 | + | { | |
| 5007 | + | "position": 1, | |
| 5008 | + | "id": "prj_01kkq0c4d9h2w7rn5f8t3k6p2z", | |
| 5009 | + | "workspace": "flagon-io", | |
| 5010 | + | "slug": "lab-api", | |
| 5011 | + | "name": "lab-api", | |
| 5012 | + | "description": null, | |
| 5013 | + | "private": true, | |
| 5014 | + | "archived": false, | |
| 5015 | + | "kind": "app", | |
| 5016 | + | "url": "https://g1t.sh/flagon-io/lab-api", | |
| 5017 | + | "pushed_at": "2026-10-06T18:40:00.000Z", | |
| 5018 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5019 | + | }, | |
| 5020 | + | { | |
| 5021 | + | "position": 2, | |
| 5022 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 5023 | + | "workspace": "flagon-io", | |
| 5024 | + | "slug": "hello", | |
| 5025 | + | "name": "hello", | |
| 5026 | + | "description": "Greets people from the command line.", | |
| 5027 | + | "private": false, | |
| 5028 | + | "archived": false, | |
| 5029 | + | "kind": "library", | |
| 5030 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 5031 | + | "pushed_at": null, | |
| 5032 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5033 | + | } | |
| 5034 | + | ], | |
| 5035 | + | "notes": "In your order: `position` 0 first. The sidebar shows these, then the projects you opened last. `pushed_at` is null until the project's repository is pushed to." | |
| 5036 | + | }, | |
| 5037 | + | "pin_project": { | |
| 5038 | + | "params": { | |
| 5039 | + | "workspace": "flagon-io", | |
| 5040 | + | "project": "hello" | |
| 5041 | + | }, | |
| 5042 | + | "request": { | |
| 5043 | + | "position": 0 | |
| 5044 | + | }, | |
| 5045 | + | "response": [ | |
| 5046 | + | { | |
| 5047 | + | "position": 0, | |
| 5048 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 5049 | + | "workspace": "flagon-io", | |
| 5050 | + | "slug": "hello", | |
| 5051 | + | "name": "hello", | |
| 5052 | + | "description": "Greets people from the command line.", | |
| 5053 | + | "private": false, | |
| 5054 | + | "archived": false, | |
| 5055 | + | "kind": "library", | |
| 5056 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 5057 | + | "pushed_at": null, | |
| 5058 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5059 | + | }, | |
| 5060 | + | { | |
| 5061 | + | "position": 1, | |
| 5062 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 5063 | + | "workspace": "flagon-io", | |
| 5064 | + | "slug": "g1t", | |
| 5065 | + | "name": "g1t", | |
| 5066 | + | "description": null, | |
| 5067 | + | "private": false, | |
| 5068 | + | "archived": false, | |
| 5069 | + | "kind": "app", | |
| 5070 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 5071 | + | "pushed_at": "2026-10-07T10:00:00.000Z", | |
| 5072 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5073 | + | }, | |
| 5074 | + | { | |
| 5075 | + | "position": 2, | |
| 5076 | + | "id": "prj_01kkq0c4d9h2w7rn5f8t3k6p2z", | |
| 5077 | + | "workspace": "flagon-io", | |
| 5078 | + | "slug": "lab-api", | |
| 5079 | + | "name": "lab-api", | |
| 5080 | + | "description": null, | |
| 5081 | + | "private": true, | |
| 5082 | + | "archived": false, | |
| 5083 | + | "kind": "app", | |
| 5084 | + | "url": "https://g1t.sh/flagon-io/lab-api", | |
| 5085 | + | "pushed_at": "2026-10-06T18:40:00.000Z", | |
| 5086 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5087 | + | } | |
| 5088 | + | ], | |
| 5089 | + | "notes": "A ninth pin in a workspace is refused with `409 conflict`: unpin one first." | |
| 5090 | + | }, | |
| 5091 | + | "unpin_project": { | |
| 5092 | + | "params": { | |
| 5093 | + | "workspace": "flagon-io", | |
| 5094 | + | "project": "lab-api" | |
| 5095 | + | }, | |
| 5096 | + | "response": [ | |
| 5097 | + | { | |
| 5098 | + | "position": 0, | |
| 5099 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 5100 | + | "workspace": "flagon-io", | |
| 5101 | + | "slug": "g1t", | |
| 5102 | + | "name": "g1t", | |
| 5103 | + | "description": null, | |
| 5104 | + | "private": false, | |
| 5105 | + | "archived": false, | |
| 5106 | + | "kind": "app", | |
| 5107 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 5108 | + | "pushed_at": "2026-10-07T10:00:00.000Z", | |
| 5109 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5110 | + | }, | |
| 5111 | + | { | |
| 5112 | + | "position": 1, | |
| 5113 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 5114 | + | "workspace": "flagon-io", | |
| 5115 | + | "slug": "hello", | |
| 5116 | + | "name": "hello", | |
| 5117 | + | "description": "Greets people from the command line.", | |
| 5118 | + | "private": false, | |
| 5119 | + | "archived": false, | |
| 5120 | + | "kind": "library", | |
| 5121 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 5122 | + | "pushed_at": null, | |
| 5123 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5124 | + | } | |
| 5125 | + | ] | |
| 5126 | + | }, | |
| 5127 | + | "reorder_pinned_projects": { | |
| 5128 | + | "params": { | |
| 5129 | + | "workspace": "flagon-io" | |
| 5130 | + | }, | |
| 5131 | + | "request": { | |
| 5132 | + | "projects": [ | |
| 5133 | + | "lab-api", | |
| 5134 | + | "g1t", | |
| 5135 | + | "hello" | |
| 5136 | + | ] | |
| 5137 | + | }, | |
| 5138 | + | "response": [ | |
| 5139 | + | { | |
| 5140 | + | "position": 0, | |
| 5141 | + | "id": "prj_01kkq0c4d9h2w7rn5f8t3k6p2z", | |
| 5142 | + | "workspace": "flagon-io", | |
| 5143 | + | "slug": "lab-api", | |
| 5144 | + | "name": "lab-api", | |
| 5145 | + | "description": null, | |
| 5146 | + | "private": true, | |
| 5147 | + | "archived": false, | |
| 5148 | + | "kind": "app", | |
| 5149 | + | "url": "https://g1t.sh/flagon-io/lab-api", | |
| 5150 | + | "pushed_at": "2026-10-06T18:40:00.000Z", | |
| 5151 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5152 | + | }, | |
| 5153 | + | { | |
| 5154 | + | "position": 1, | |
| 5155 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 5156 | + | "workspace": "flagon-io", | |
| 5157 | + | "slug": "g1t", | |
| 5158 | + | "name": "g1t", | |
| 5159 | + | "description": null, | |
| 5160 | + | "private": false, | |
| 5161 | + | "archived": false, | |
| 5162 | + | "kind": "app", | |
| 5163 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 5164 | + | "pushed_at": "2026-10-07T10:00:00.000Z", | |
| 5165 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5166 | + | }, | |
| 5167 | + | { | |
| 5168 | + | "position": 2, | |
| 5169 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 5170 | + | "workspace": "flagon-io", | |
| 5171 | + | "slug": "hello", | |
| 5172 | + | "name": "hello", | |
| 5173 | + | "description": "Greets people from the command line.", | |
| 5174 | + | "private": false, | |
| 5175 | + | "archived": false, | |
| 5176 | + | "kind": "library", | |
| 5177 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 5178 | + | "pushed_at": null, | |
| 5179 | + | "updated_at": "2026-10-01T09:12:00.000Z" | |
| 5180 | + | } | |
| 5181 | + | ], | |
| 5182 | + | "notes": "Name every pinned project once; anything else is refused with `422 invalid`." | |
| 4980 | 5183 | } | |
| 4981 | 5184 | } |
| 79 | 79 | route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]), | |
| 80 | 80 | route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]), | |
| 81 | 81 | route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]), | |
| 82 | + | // Your pinned projects in a workspace, in your order. | |
| 83 | + | route("GET", "/user/pinned_projects/:workspace", Op::ListPinnedProjects, &[]), | |
| 84 | + | route("PUT", "/user/pinned_projects/:workspace", Op::ReorderPinnedProjects, &[]), | |
| 85 | + | route("PUT", "/user/pinned_projects/:workspace/:project", Op::PinProject, &[]), | |
| 86 | + | route("DELETE", "/user/pinned_projects/:workspace/:project", Op::UnpinProject, &[]), | |
| 82 | 87 | route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]), | |
| 83 | 88 | route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]), | |
| 84 | 89 | route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]), |
| 227 | 227 | Tool { | |
| 228 | 228 | name: "workspace", | |
| 229 | 229 | title: "Workspaces", | |
| 230 | − | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, and connect integrations and model providers.", | |
| 230 | + | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, and keep your own pinned projects at the top of its sidebar.", | |
| 231 | 231 | default_action: None, | |
| 232 | 232 | actions: &[ | |
| 233 | 233 | a("create", Op::CreateWorkspace, "Create a workspace"), | |
| 242 | 242 | a("test_integration", Op::TestIntegration, "Check its credentials"), | |
| 243 | 243 | a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"), | |
| 244 | 244 | a("set_model_routes", Op::SetModelRoutes, "Replace them"), | |
| 245 | + | a("list_pinned_projects", Op::ListPinnedProjects, "Your pinned projects in it, in your order"), | |
| 246 | + | a("pin_project", Op::PinProject, "Pin a project, at a position or the end"), | |
| 247 | + | a("unpin_project", Op::UnpinProject, "Unpin a project"), | |
| 248 | + | a("reorder_pinned_projects", Op::ReorderPinnedProjects, "Put your pins in a new order"), | |
| 245 | 249 | ], | |
| 246 | 250 | }, | |
| 247 | 251 | Tool { |
| 29 | 29 | { "binding": "CONTEXT", "service": "g1t-context" }, | |
| 30 | 30 | { "binding": "SEARCH", "service": "g1t-search" }, | |
| 31 | 31 | // Secret and dependency alerts: list_security_alerts and dismissing them. | |
| 32 | − | { "binding": "SECURITY", "service": "g1t-security" } | |
| 32 | + | { "binding": "SECURITY", "service": "g1t-security" }, | |
| 33 | + | // A person's pinned projects: list_pinned_projects and changing them. | |
| 34 | + | { "binding": "PROJECTS", "service": "g1t-projects" } | |
| 33 | 35 | ], | |
| 34 | 36 | // GitHub Actions artifacts, in chunks, with KV's own expiry (and cache | |
| 35 | 37 | // entries saved before the cache moved to R2, until they expire). |
| 105 | 105 | ||
| 106 | 106 | Owners always have Admin, whatever it says. Only owners can change it: | |
| 107 | 107 | ||
| 108 | − | 1. Open the workspace's **Members** in the sidebar, `g1t.sh/<workspace>/-/people`. Every member can see it; owners manage it. | |
| 108 | + | 1. Open the workspace's **People** in the sidebar, `g1t.sh/<workspace>/-/people`. Every member can see it; owners manage it. | |
| 109 | 109 | 2. Under **Base permission**, choose one. | |
| 110 | 110 | ||
| 111 | 111 | It takes effect on everyone's next request. To give one member more on | |
| 179 | 179 | ||
| 180 | 180 | Owners see every outside collaborator, and the repositories and roles each | |
| 181 | 181 | has, on the **Outside collaborators** tab of the workspace's | |
| 182 | − | **Members**. **Convert to member** adds one to the workspace | |
| 182 | + | **People**. **Convert to member** adds one to the workspace | |
| 183 | 183 | (see [members and roles](/guides/workspaces/#members-and-roles)); the | |
| 184 | 184 | roles they have stay, and the base permission adds to them. | |
| 185 | 185 |
| 351 | 351 | | `workflows:write` | Run, cancel, rerun and turn workflows on or off | | |
| 352 | 352 | | `memory:read` | Recall memory and search the workspace's context | | |
| 353 | 353 | | `memory:write` | Save memory for the next agent | | |
| 354 | − | | `account:read` | Read your email addresses, invites and invitations | | |
| 355 | − | | `account:write` | Change your email addresses, make invites and answer invitations | | |
| 354 | + | | `account:read` | Read your email addresses, invites, invitations and pinned projects | | |
| 355 | + | | `account:write` | Change your email addresses, make invites, answer invitations and pin projects | | |
| 356 | 356 | | `notifications:read` | See your [inbox](/guides/inbox/), its threads, and what you subscribe to and watch | | |
| 357 | 357 | | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories | | |
| 358 | 358 | | `workspace:read` | Read workspace invites, integrations and model routes | |
| 24 | 24 | repository. Repositories made before projects existed became projects the | |
| 25 | 25 | first time their workspace was opened. | |
| 26 | 26 | ||
| 27 | − | A workspace's page, `g1t.sh/<workspace>`, shows its projects first, each | |
| 28 | − | with where it is deployed, its latest build, and its open issues and pull | |
| 29 | − | requests. The sidebar lists them too. | |
| 27 | + | A workspace's page, `g1t.sh/<workspace>`, shows your pinned projects and | |
| 28 | + | its most active ones, each with where it is deployed, its latest build, and | |
| 29 | + | its open issues and pull requests. Its **Projects** tab lists every one, | |
| 30 | + | with search, filters and sorting; see | |
| 31 | + | [the Projects tab](/guides/workspaces/#the-projects-tab). The sidebar keeps | |
| 32 | + | your [pinned and recent projects](/guides/workspaces/#pinned-and-recent-projects). | |
| 30 | 33 | ||
| 31 | 34 | ## A project's pages | |
| 32 | 35 |
| 270 | 270 | | Owner | Everything a member can, and manage members, the base permission, the workspace's access tokens, its details, and billing: the plan, card checks, prepayment and limits. Admin on every repository, and the only ones who can transfer and delete them; see [access and roles](/guides/access-and-roles/). | | |
| 271 | 271 | ||
| 272 | 272 | Whoever creates a workspace is its owner. An owner adds people on the | |
| 273 | − | workspace's **Members** (in the sidebar), `g1t.sh/<workspace>/-/people`: | |
| 273 | + | workspace's **People**, `g1t.sh/<workspace>/-/people` (a tab of the | |
| 274 | + | workspace's page, and in the sidebar): | |
| 274 | 275 | ||
| 275 | 276 | - **By username**: someone already on g1t joins at once, as a member. | |
| 276 | 277 | - **By email address**: g1t emails an invite that only that address can | |
| 294 | 295 | To give someone a role on one repository without making them a member, | |
| 295 | 296 | add them as an [outside collaborator](/guides/access-and-roles/#outside-collaborators). | |
| 296 | 297 | ||
| 297 | − | ## The workspace's pages | |
| 298 | + | ## The workspace's page | |
| 299 | + | ||
| 300 | + | A workspace's own page, `g1t.sh/<workspace>`, has its icon, name, address | |
| 301 | + | and description at the top, and tabs under them: | |
| 298 | 302 | ||
| 299 | − | A workspace's page, `g1t.sh/<workspace>`, shows its repositories and the | |
| 300 | − | pull requests in progress across them. Each person sees the repositories | |
| 301 | − | they can read: a member whose base permission is None, an | |
| 302 | − | [outside collaborator](/guides/access-and-roles/#outside-collaborators) | |
| 303 | + | | Tab | Address | Who | | | |
| 304 | + | | --- | --- | --- | --- | | |
| 305 | + | | **Overview** | `g1t.sh/<workspace>` | Everyone | Your [pinned projects](#pinned-and-recent-projects), then the most active ones, the pull requests in progress across them, and **All projects**. Members also see a **Usage** card with this month's spend, and who belongs. | | |
| 306 | + | | **Projects** | `/-/projects` | Everyone | Every project you can see, with their count. See [the Projects tab](#the-projects-tab). | | |
| 307 | + | | [**Packages**](/guides/packages/) | `/-/packages` | Everyone | What the workspace publishes. | | |
| 308 | + | | **Teams** | `/-/teams` | Members | Coming soon: groups of members given roles together. | | |
| 309 | + | | **People** | `/-/people` | Members | Who belongs, with their count. Owners add and remove people here. | | |
| 310 | + | | **Insights** | `/-/insights` | Members | Coming soon: how the whole workspace delivers. | | |
| 311 | + | | **Settings** | `/-/settings` | Owners | How the workspace is set up and connected (below). | | |
| 312 | + | ||
| 313 | + | Each person sees the projects they can read: a member whose base permission | |
| 314 | + | is None, an [outside collaborator](/guides/access-and-roles/#outside-collaborators) | |
| 303 | 315 | or a visitor sees the public ones and those shared with them, without the | |
| 304 | − | workspace's members, deployments or settings. Its members also see a | |
| 305 | − | **Usage** card there with this month's spend. | |
| 316 | + | workspace's people, deployments or settings. | |
| 317 | + | ||
| 318 | + | Older addresses still work: `/-/members` opens People, and | |
| 319 | + | `g1t.sh/<workspace>?tab=projects` (or `repositories`, `packages`, | |
| 320 | + | `people`) opens that tab. | |
| 321 | + | ||
| 322 | + | ### The Projects tab | |
| 323 | + | ||
| 324 | + | The Projects tab is made for workspaces with hundreds of projects: | |
| 306 | 325 | ||
| 307 | − | The sidebar is one list, in groups: **Mission control**, the workspace's | |
| 308 | − | **Overview** and **Explore**; its projects; the places work happens across | |
| 309 | − | them (**Agent fleet**, **Context**, **Memory**, **Security** and | |
| 310 | − | [**Packages**](/guides/packages/)); then who belongs (**Members**, and | |
| 311 | − | **Teams** soon), **Usage**, what g1t's runs have cost (see | |
| 312 | − | [usage and billing](/guides/usage-and-billing/)), **Support** and | |
| 326 | + | - **Find a project** matches every word you type in a project's name, its | |
| 327 | + | address or its description. Press <kbd>/</kbd> anywhere on the page to | |
| 328 | + | start typing. | |
| 329 | + | - **Filters**: public or private; apps or libraries; the language its | |
| 330 | + | manifests say it is written in; only projects with | |
| 331 | + | [Deployments](/guides/deployments/) on; and archived projects, which are | |
| 332 | + | left out unless you ask for them. Each choice shows how many projects it | |
| 333 | + | holds. | |
| 334 | + | - **Sort** by recently updated (its settings or its last push, whichever is | |
| 335 | + | later), recently pushed, most active, or name. Most active counts each | |
| 336 | + | push, issue or pull request opened or closed, review, comment and | |
| 337 | + | deployment, and what happened a week ago counts half as much. | |
| 338 | + | - **List** or **grid**, 30 projects to a page. | |
| 339 | + | - The arrow keys (or <kbd>j</kbd> and <kbd>k</kbd>) move between projects, | |
| 340 | + | and <kbd>Enter</kbd> opens one. | |
| 341 | + | ||
| 342 | + | Everything you choose is in the address, so a filtered list can be | |
| 343 | + | bookmarked or shared. | |
| 344 | + | ||
| 345 | + | ## The sidebar | |
| 346 | + | ||
| 347 | + | The sidebar is always about one workspace: the one the switcher at its top | |
| 348 | + | names. Choose the workspace's name to open its page, or the arrows beside | |
| 349 | + | it to switch, or for **Workspace overview** and **All projects**. | |
| 350 | + | [Explore](https://g1t.sh/explore), public projects from all of g1t, is in | |
| 351 | + | the top bar, beside **Docs**. | |
| 352 | + | ||
| 353 | + | In order, it lists **Mission control**; the workspace's | |
| 354 | + | [projects](#pinned-and-recent-projects); under **Workspace**, the places work | |
| 355 | + | happens across them (**Agent fleet**, **Context**, **Memory**, **Security** | |
| 356 | + | and [**Packages**](/guides/packages/), with **Boards** and **Roadmap** | |
| 357 | + | soon); then **People** (and **Teams** soon), **Usage**, what g1t's runs have | |
| 358 | + | cost (see [usage and billing](/guides/usage-and-billing/)), **Support** and | |
| 313 | 359 | **Settings**. An item with an arrow opens a list of its own in the sidebar: | |
| 314 | 360 | **Settings** slides over to how the workspace is set up and connected, and | |
| 315 | 361 | the row at the top, **‹ Settings**, slides back: | |
| 327 | 373 | | **Billing and plans** | Members | [The g1t plan](/guides/usage-and-billing/#the-g1t-plan), [limits](/guides/usage-and-billing/#limits) and the statement. Owners start the plan, check a card, prepay and set limits. | | |
| 328 | 374 | | **Audit log** | Members | [Every action agents, people and tokens took](/guides/audit-log/). | | |
| 329 | 375 | ||
| 330 | − | **Members** is in the main list, for every member to see; owners add and | |
| 376 | + | **People** is in the main list, for every member to see; owners add and | |
| 331 | 377 | remove people there, set the | |
| 332 | 378 | [base permission](/guides/access-and-roles/#the-base-permission), and see | |
| 333 | 379 | the **Outside collaborators** tab. | |
| 340 | 386 | for the roles that can use it. A link straight to any of these pages opens | |
| 341 | 387 | the sidebar already there. | |
| 342 | 388 | ||
| 389 | + | ### Pinned and recent projects | |
| 390 | + | ||
| 391 | + | However many projects a workspace has, its sidebar lists a few: | |
| 392 | + | ||
| 393 | + | - **Pinned**: the projects you pinned, in your order, up to eight a | |
| 394 | + | workspace. Pin one with **Pin** on its page, or the pin on its row of the | |
| 395 | + | Projects tab or its card on the Overview. Drag a pinned project to move | |
| 396 | + | it, or hold <kbd>Alt</kbd> and press the up or down arrow. | |
| 397 | + | - **Recent**: the projects you opened last that you have not pinned, up to | |
| 398 | + | five. | |
| 399 | + | - **All projects**, with how many there are, opens the Projects tab. | |
| 400 | + | ||
| 401 | + | Pins and recent projects are yours: nobody else sees them, and each | |
| 402 | + | workspace has its own. ⌘K finds any project in the workspace, pinned or not. | |
| 403 | + | From the API, use | |
| 404 | + | [`GET /user/pinned_projects/{workspace}`](/reference/api/pinned-projects/list-pinned-projects/) | |
| 405 | + | and the other [pinned projects](/reference/api/pinned-projects/list-pinned-projects/) | |
| 406 | + | operations, or the `workspace` tool's `list_pinned_projects`, | |
| 407 | + | `pin_project`, `unpin_project` and `reorder_pinned_projects` actions | |
| 408 | + | over MCP. | |
| 409 | + | ||
| 343 | 410 | ## Mission control | |
| 344 | 411 | ||
| 345 | 412 | Mission control, `g1t.sh` when you are signed in, is your home page. It |
| 432 | 432 | ||
| 433 | 433 | ## `workspace` | |
| 434 | 434 | ||
| 435 | − | Workspaces own repositories: create, update or delete one, invite members, and | |
| 436 | − | connect [integrations](/guides/integrations/) and model providers. See | |
| 437 | − | [workspaces](/guides/workspaces/). | |
| 435 | + | Workspaces own repositories: create, update or delete one, invite members, | |
| 436 | + | connect [integrations](/guides/integrations/) and model providers, and keep | |
| 437 | + | your own [pinned projects](/guides/workspaces/#pinned-and-recent-projects) | |
| 438 | + | at the top of its sidebar. See [workspaces](/guides/workspaces/). | |
| 438 | 439 | ||
| 439 | 440 | | Action | What it does | Required | Scope | | |
| 440 | 441 | | --- | --- | --- | --- | | |
| 450 | 451 | | [`test_integration`](/reference/api/integrations/test-integration/) | Check its credentials against the system it connects to. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 451 | 452 | | [`get_model_routes`](/reference/api/integrations/get-model-routes/) | Which provider and model each kind of work goes to. Members only. | `workspace` | `workspace:read` | | |
| 452 | 453 | | [`set_model_routes`](/reference/api/integrations/set-model-routes/) | Replace them: each route has `task`, `connection_id` (null for g1t's models) and `model`. Owners only. | `workspace`, `routes` | `workspace:admin` | | |
| 454 | + | | [`list_pinned_projects`](/reference/api/pinned-projects/list-pinned-projects/) | Your pinned projects in it, in your order, each with its `position`. Your own: a personal token or an OAuth sign-in. | `workspace` | `account:read` | | |
| 455 | + | | [`pin_project`](/reference/api/pinned-projects/pin-project/) | Pin a project you can see, at `position` (0 first) or at the end; at most 8 a workspace. Returns your pins. | `workspace`, `project` | `account:write` | | |
| 456 | + | | [`unpin_project`](/reference/api/pinned-projects/unpin-project/) | Unpin it. Returns your pins. | `workspace`, `project` | `account:write` | | |
| 457 | + | | [`reorder_pinned_projects`](/reference/api/pinned-projects/reorder-pinned-projects/) | Put your pins in a new order: `projects` names each pinned project's slug once. | `workspace`, `projects` | `account:write` | | |
| 453 | 458 | ||
| 454 | 459 | ## `notifications` | |
| 455 | 460 |
Binary or large file; its contents are not shown.
| 304 | 304 | }; | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | − | type Live = { runs: AgentRun[]; member: boolean }; | |
| 307 | + | export type Live = { runs: AgentRun[]; member: boolean }; | |
| 308 | 308 | ||
| 309 | 309 | /** | |
| 310 | 310 | * The runs of a project, fetched from its `agents.json`, every few seconds | |
| 311 | − | * while any is running. For pages whose own loader does not have them. | |
| 311 | + | * while any is running. A page whose loader read them passes them as | |
| 312 | + | * `initial`, so they come with the page instead of appearing after it; | |
| 313 | + | * without, they are fetched once it is up. | |
| 312 | 314 | */ | |
| 313 | − | export function useRuns(owner: string, repo: string, query: Record<string, string>): Live | null { | |
| 315 | + | export function useRuns( | |
| 316 | + | owner: string, | |
| 317 | + | repo: string, | |
| 318 | + | query: Record<string, string>, | |
| 319 | + | initial?: Live | null, | |
| 320 | + | ): Live | null { | |
| 314 | 321 | const fetcher = useFetcher<Live>(); | |
| 315 | 322 | const search = new URLSearchParams(query).toString(); | |
| 316 | 323 | const url = `/${owner}/${repo}/agents.json?${search}`; | |
| 317 | 324 | const { load } = fetcher; | |
| 325 | + | const loaded = initial !== undefined; | |
| 318 | 326 | useEffect(() => { | |
| 319 | − | load(url); | |
| 320 | − | }, [load, url]); | |
| 321 | − | const live = fetcher.data?.runs.some((run) => isActiveRun(run.status)) ?? false; | |
| 327 | + | if (!loaded) load(url); | |
| 328 | + | }, [load, url, loaded]); | |
| 329 | + | // The newer of the page's runs and the last poll: the page revalidating | |
| 330 | + | // replaces an older poll, and a poll the runs the page came with. | |
| 331 | + | const [newest, setNewest] = useState<Live | null>(initial ?? null); | |
| 332 | + | useEffect(() => { | |
| 333 | + | if (loaded) setNewest(initial ?? null); | |
| 334 | + | }, [loaded, initial]); | |
| 335 | + | useEffect(() => { | |
| 336 | + | if (fetcher.data) setNewest(fetcher.data); | |
| 337 | + | }, [fetcher.data]); | |
| 338 | + | const data = loaded ? newest : (fetcher.data ?? null); | |
| 339 | + | const live = data?.runs.some((run) => isActiveRun(run.status)) ?? false; | |
| 322 | 340 | useEffect(() => { | |
| 323 | 341 | if (!live) return; | |
| 324 | 342 | const timer = setInterval(() => { | |
| 326 | 344 | }, LIVE_MS); | |
| 327 | 345 | return () => clearInterval(timer); | |
| 328 | 346 | }, [live, load, url]); | |
| 329 | − | return fetcher.data ?? null; | |
| 347 | + | return data; | |
| 330 | 348 | } | |
| 331 | 349 | ||
| 332 | 350 | /** "Agent confidence: Low — tests not added, 3 revisions", and what the agent said it was unsure of. */ | |
| 361 | 379 | number, | |
| 362 | 380 | stage, | |
| 363 | 381 | confidence, | |
| 382 | + | runs: initial, | |
| 364 | 383 | }: { | |
| 365 | 384 | owner: string; | |
| 366 | 385 | repo: string; | |
| 368 | 387 | stage?: Stage | null; | |
| 369 | 388 | /** How sure g1t is of the change, once the agent has finished it. */ | |
| 370 | 389 | confidence?: Confidence | null; | |
| 390 | + | /** Its latest five runs, from the page's loader, so the panel comes with the page. */ | |
| 391 | + | runs?: Live | null; | |
| 371 | 392 | }) { | |
| 372 | − | const data = useRuns(owner, repo, { number: String(number), limit: "5" }); | |
| 393 | + | const data = useRuns(owner, repo, { number: String(number), limit: "5" }, initial); | |
| 373 | 394 | const runs = data?.runs ?? []; | |
| 374 | 395 | const member = data?.member ?? false; | |
| 375 | 396 | const current = runs.find((run) => isActiveRun(run.status)) ?? runs[0]; | |
| 464 | 485 | } | |
| 465 | 486 | ||
| 466 | 487 | /** The active run on each pull request of a project, by number. */ | |
| 467 | − | export function useActiveRuns(owner: string, repo: string): Map<number, AgentRun> { | |
| 468 | − | const data = useRuns(owner, repo, { active: "1", limit: "100" }); | |
| 488 | + | export function useActiveRuns(owner: string, repo: string, initial?: Live | null): Map<number, AgentRun> { | |
| 489 | + | const data = useRuns(owner, repo, { active: "1", limit: "100" }, initial); | |
| 469 | 490 | return useMemo(() => { | |
| 470 | 491 | const byNumber = new Map<number, AgentRun>(); | |
| 471 | 492 | for (const run of data?.runs ?? []) { | |
| 490 | 511 | * What the agent on a pull request is doing this minute, as one line, for | |
| 491 | 512 | * an issue's sidebar: which agent picked the issue up, and where it is. | |
| 492 | 513 | */ | |
| 493 | − | export function AgentStepLine({ owner, repo, number }: { owner: string; repo: string; number: number }) { | |
| 494 | − | const data = useRuns(owner, repo, { number: String(number), active: "1", limit: "1" }); | |
| 495 | − | const run = data?.runs[0]; | |
| 514 | + | export function AgentStepLine({ | |
| 515 | + | owner, | |
| 516 | + | repo, | |
| 517 | + | number, | |
| 518 | + | runs: initial, | |
| 519 | + | }: { | |
| 520 | + | owner: string; | |
| 521 | + | repo: string; | |
| 522 | + | number: number; | |
| 523 | + | /** The project's active runs, from the page's loader, so the line comes with the page. */ | |
| 524 | + | runs?: Live | null; | |
| 525 | + | }) { | |
| 526 | + | const data = useRuns(owner, repo, { number: String(number), active: "1", limit: "1" }, initial); | |
| 527 | + | const run = data?.runs.find((run) => run.number === number && isActiveRun(run.status)); | |
| 496 | 528 | if (!run?.step) return null; | |
| 497 | 529 | return ( | |
| 498 | 530 | <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={shownStep(run.step)}> |
| 11 | 11 | ||
| 12 | 12 | import { actionLabel, actorLabel, ruleLabel, targetLabel } from "../lib/audit"; | |
| 13 | 13 | import { Avatar, TimeAgo } from "./ui"; | |
| 14 | + | import { Loading, SkeletonRows } from "./ui/skeleton"; | |
| 14 | 15 | ||
| 15 | 16 | function clock(at: string): string { | |
| 16 | 17 | return new Date(at).toISOString().slice(11, 19); | |
| 168 | 169 | return () => clearInterval(timer); | |
| 169 | 170 | }, [live, load, url]); | |
| 170 | 171 | const entries = fetcher.data?.entries; | |
| 171 | − | if (!entries || entries.length === 0) return null; | |
| 172 | + | if (runIds.length === 0) return null; | |
| 173 | + | // There from the start, so the panel does not grow when the log arrives. | |
| 172 | 174 | return ( | |
| 173 | 175 | <details className="mt-3 group"> | |
| 174 | 176 | <summary className="cursor-pointer text-xs text-muted hover:text-fg">What it did</summary> | |
| 175 | − | <WhatItDid entries={entries} compact /> | |
| 177 | + | {entries ? ( | |
| 178 | + | <WhatItDid entries={entries} compact /> | |
| 179 | + | ) : ( | |
| 180 | + | <Loading className="mt-3 overflow-hidden rounded-xl border border-line bg-surface"> | |
| 181 | + | <SkeletonRows rows={3} rowClassName="h-9 px-4" /> | |
| 182 | + | </Loading> | |
| 183 | + | )} | |
| 176 | 184 | <Link to={`/${owner}/-/audit?project=${encodeURIComponent(repo)}&kind=agent`} className="mt-2 inline-block text-xs text-muted hover:text-fg"> | |
| 177 | 185 | Open the audit log | |
| 178 | 186 | </Link> |
| 27 | 27 | import { useAddresses } from "../lib/addresses"; | |
| 28 | 28 | import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up"; | |
| 29 | 29 | import { duration } from "./actions"; | |
| 30 | − | import { Elapsed, useRuns } from "./agents"; | |
| 30 | + | import { Elapsed, type Live, useRuns } from "./agents"; | |
| 31 | 31 | import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 32 | 32 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 33 | + | import { SkeletonLine } from "./ui/skeleton"; | |
| 33 | 34 | ||
| 34 | 35 | // --- Output --------------------------------------------------------------- | |
| 35 | 36 | ||
| 210 | 211 | name, | |
| 211 | 212 | detail, | |
| 212 | 213 | time, | |
| 214 | + | timing = false, | |
| 213 | 215 | to, | |
| 214 | 216 | }: { | |
| 215 | 217 | standing: Standing; | |
| 216 | 218 | name: React.ReactNode; | |
| 217 | 219 | detail?: React.ReactNode; | |
| 218 | 220 | time?: string; | |
| 221 | + | /** Its jobs and their times are still being read. */ | |
| 222 | + | timing?: boolean; | |
| 219 | 223 | to?: string; | |
| 220 | 224 | }) { | |
| 221 | 225 | return ( | |
| 225 | 229 | {name} | |
| 226 | 230 | {detail && <span className="text-muted"> — {detail}</span>} | |
| 227 | 231 | </span> | |
| 228 | − | {time && <span className="shrink-0 font-mono text-xs text-faint">{time}</span>} | |
| 232 | + | {time && <span className="shrink-0 animate-fade-in font-mono text-xs text-faint">{time}</span>} | |
| 233 | + | {timing && <SkeletonLine className="w-10 shrink-0 text-xs" />} | |
| 229 | 234 | {to && ( | |
| 230 | 235 | <Link to={to} className="shrink-0 text-xs text-muted hover:text-fg hover:underline"> | |
| 231 | 236 | Details | |
| 313 | 318 | canRerunWorkflows, | |
| 314 | 319 | settingsUrl, | |
| 315 | 320 | error, | |
| 321 | + | loading = false, | |
| 316 | 322 | }: { | |
| 317 | 323 | /** Set when the merge queue took it out, or for a run from before checks were workflows. */ | |
| 318 | 324 | run: CheckRun | null; | |
| 328 | 334 | /** Where the required checks are chosen, for those who may. */ | |
| 329 | 335 | settingsUrl?: string | null; | |
| 330 | 336 | error?: string | null; | |
| 337 | + | /** The workflow runs' jobs are still being read: their rows wait, busy. */ | |
| 338 | + | loading?: boolean; | |
| 331 | 339 | }) { | |
| 332 | 340 | if (!run && statuses.length === 0 && required.length === 0) return null; | |
| 333 | 341 | ||
| 379 | 387 | const failedRuns = statuses.filter((status) => statusStanding(status) === "failed" && runIdOf(status)); | |
| 380 | 388 | ||
| 381 | 389 | return ( | |
| 382 | − | <div> | |
| 390 | + | <div aria-busy={loading || undefined}> | |
| 383 | 391 | <div className="flex items-start gap-3 px-4 py-3"> | |
| 384 | 392 | <span className="mt-0.5 shrink-0"> | |
| 385 | 393 | {standing === "failed" || queueFailed ? ( | |
| 466 | 474 | standing={statusStanding(status)} | |
| 467 | 475 | name={status.context} | |
| 468 | 476 | detail={status.description ?? undefined} | |
| 477 | + | timing={loading && id != null} | |
| 469 | 478 | to={runPath} | |
| 470 | 479 | /> | |
| 471 | 480 | ); | |
| 775 | 784 | update, | |
| 776 | 785 | startedAt, | |
| 777 | 786 | retrying, | |
| 787 | + | runs: initial, | |
| 778 | 788 | }: { | |
| 779 | 789 | owner: string; | |
| 780 | 790 | repo: string; | |
| 786 | 796 | startedAt: number; | |
| 787 | 797 | /** Whether a retry is on its way. */ | |
| 788 | 798 | retrying: boolean; | |
| 799 | + | /** The pull request's latest runs, from the page's loader. */ | |
| 800 | + | runs?: Live | null; | |
| 789 | 801 | }) { | |
| 790 | 802 | // The page revalidates while this is working, which reloads the runs too. | |
| 791 | − | const data = useRuns(owner, repo, { number: String(number), limit: "5" }); | |
| 803 | + | const data = useRuns(owner, repo, { number: String(number), limit: "5" }, initial); | |
| 792 | 804 | const [now, setNow] = useState(() => Date.now()); | |
| 793 | 805 | useEffect(() => { | |
| 794 | 806 | const timer = setInterval(() => setNow(Date.now()), 5000); |
| 15 | 15 | import { Avatar, CopyLine, TimeAgo, notACredential } from "./ui"; | |
| 16 | 16 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 17 | 17 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 18 | + | import { SkeletonLine } from "./ui/skeleton"; | |
| 18 | 19 | ||
| 19 | 20 | function encodePath(path: string): string { | |
| 20 | 21 | return path.split("/").map(encodeURIComponent).join("/"); | |
| 238 | 239 | }) { | |
| 239 | 240 | const byName = new Map((last?.entries ?? []).map((entry) => [entry.name, entry.commit])); | |
| 240 | 241 | return ( | |
| 241 | − | <ul className="divide-y divide-line text-sm"> | |
| 242 | + | // Busy while each entry's last commit is on its way; its cells keep their size. | |
| 243 | + | <ul aria-busy={last === undefined || undefined} className="divide-y divide-line text-sm"> | |
| 242 | 244 | {entries.map((entry) => { | |
| 243 | 245 | const isTree = entry.kind === "tree"; | |
| 244 | 246 | const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File; | |
| 256 | 258 | {commit ? ( | |
| 257 | 259 | <Link | |
| 258 | 260 | to={`${base}/commit/${commit.hash}`} | |
| 259 | − | className="block truncate text-muted hover:text-fg hover:underline" | |
| 261 | + | className="block animate-fade-in truncate text-muted hover:text-fg hover:underline" | |
| 260 | 262 | title={commit.message.split("\n")[0]} | |
| 261 | 263 | > | |
| 262 | 264 | {commit.message.split("\n")[0]} | |
| 263 | 265 | </Link> | |
| 264 | 266 | ) : last === undefined ? ( | |
| 265 | − | <span aria-hidden="true" className="block h-3 w-40 max-w-full animate-pulse rounded bg-raised" /> | |
| 267 | + | <SkeletonLine width="10rem" barClassName="max-w-full" /> | |
| 266 | 268 | ) : null} | |
| 267 | 269 | </span> | |
| 268 | 270 | <span className="text-right text-xs whitespace-nowrap text-faint"> | |
| 269 | − | {commit ? <TimeAgo at={commit.authoredAt} /> : last === undefined ? <span aria-hidden="true" className="inline-block h-3 w-12 animate-pulse rounded bg-raised" /> : null} | |
| 271 | + | {commit ? <span className="animate-fade-in"><TimeAgo at={commit.authoredAt} /></span> : last === undefined ? <SkeletonLine className="justify-end" barClassName="w-12" /> : null} | |
| 270 | 272 | </span> | |
| 271 | 273 | </li> | |
| 272 | 274 | ); |
| 1 | − | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| 27 | 27 | import { withNext } from "../lib/next"; | |
| 28 | 28 | import { useSignUpCopy } from "../lib/registration"; | |
| 29 | 29 | import { STATUS_URL, statusTitle } from "../lib/status"; | |
| 30 | + | import { type ShortcutProject, movedPin, recentWith } from "../lib/pins"; | |
| 30 | 31 | import type { AccountMenuData } from "../routes/settings-menu-json"; | |
| 31 | 32 | ||
| 32 | 33 | /** | |
| 37 | 38 | export type ShellData = { | |
| 38 | 39 | /** The workspace the sidebar is about: the one being looked at, or their first. */ | |
| 39 | 40 | workspace: Membership | null; | |
| 40 | − | /** Its projects, by name: `name` is the slug in their address. */ | |
| 41 | − | repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[]; | |
| 41 | + | /** All its projects, by name, for the palette: `name` is the slug in their address. */ | |
| 42 | + | repos: ShortcutProject[]; | |
| 43 | + | /** The person's pinned projects in it, in their order (lib/pins.ts). */ | |
| 44 | + | pinned?: ShortcutProject[]; | |
| 45 | + | /** What they opened there last, latest first, leaving out the pinned. */ | |
| 46 | + | recent?: ShortcutProject[]; | |
| 42 | 47 | /** Repositories shared with them in workspaces they do not belong to. */ | |
| 43 | 48 | shared?: { namespace: string; name: string; isPrivate: boolean }[]; | |
| 44 | 49 | /** The project being looked at, if any, whoever owns it. */ | |
| 250 | 255 | <ChevronsUpDown size={14} /> | |
| 251 | 256 | </DropdownMenuTrigger> | |
| 252 | 257 | <DropdownMenuContent align="start" className="w-64"> | |
| 258 | + | {workspace && ( | |
| 259 | + | <> | |
| 260 | + | {/* The workspace's own page: its projects, packages and people. */} | |
| 261 | + | <DropdownMenuItem asChild> | |
| 262 | + | <Link to={`/${workspace.slug}`}> | |
| 263 | + | <LayoutGrid /> | |
| 264 | + | Workspace overview | |
| 265 | + | </Link> | |
| 266 | + | </DropdownMenuItem> | |
| 267 | + | <DropdownMenuItem asChild> | |
| 268 | + | <Link to={`/${workspace.slug}/-/projects`}> | |
| 269 | + | <Box /> | |
| 270 | + | All projects | |
| 271 | + | </Link> | |
| 272 | + | </DropdownMenuItem> | |
| 273 | + | <DropdownMenuSeparator /> | |
| 274 | + | </> | |
| 275 | + | )} | |
| 253 | 276 | <DropdownMenuLabel>Workspaces</DropdownMenuLabel> | |
| 254 | 277 | {(user.workspaces ?? []).map((membership) => ( | |
| 255 | 278 | <DropdownMenuItem asChild key={membership.slug}> | |
| 550 | 573 | return <div role="separator" className="mx-2 my-2.5 h-px bg-line" />; | |
| 551 | 574 | } | |
| 552 | 575 | ||
| 576 | + | /** A quiet heading inside a group: Pinned, Recent. */ | |
| 577 | + | function SidebarSubhead({ children }: { children: ReactNode }) { | |
| 578 | + | return <h3 className="px-2 pt-1.5 pb-0.5 text-[0.6875rem] font-medium tracking-wide text-faint">{children}</h3>; | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | /** | |
| 582 | + | * The workspace's projects as the sidebar keeps them, however many there | |
| 583 | + | * are: the person's pins in their order, what they opened last, and the | |
| 584 | + | * way to all of them. Pins move by dragging, or with Alt and the arrow | |
| 585 | + | * keys; the projects service keeps the order. | |
| 586 | + | */ | |
| 587 | + | function SidebarProjects({ slug, shell }: { slug: string; shell: ShellData }) { | |
| 588 | + | const reorder = useFetcher({ key: `pins:${slug}` }); | |
| 589 | + | const saved = shell.pinned ?? []; | |
| 590 | + | // While a new order is on its way, it shows as made. | |
| 591 | + | const asked = reorder.formData?.get("intent") === "reorder" ? reorder.formData.getAll("slug").map(String) : null; | |
| 592 | + | const pinned = asked | |
| 593 | + | ? asked.map((name) => saved.find((project) => project.name === name)).filter((project): project is ShortcutProject => project != null) | |
| 594 | + | : saved; | |
| 595 | + | const active = shell.repo ? { namespace: shell.repo.namespace, name: shell.repo.name, isPrivate: false } : null; | |
| 596 | + | const recent = recentWith(shell.recent ?? [], pinned, active, slug); | |
| 597 | + | const [dragging, setDragging] = useState<number | null>(null); | |
| 598 | + | const move = (from: number, by: number) => { | |
| 599 | + | const next = movedPin(pinned, from, by); | |
| 600 | + | if (!next) return; | |
| 601 | + | const form = new FormData(); | |
| 602 | + | form.set("intent", "reorder"); | |
| 603 | + | for (const project of next) form.append("slug", project.name); | |
| 604 | + | reorder.submit(form, { method: "post", action: `/${slug}/-/pins` }); | |
| 605 | + | }; | |
| 606 | + | const row = (project: ShortcutProject) => ( | |
| 607 | + | <SidebarLink to={`/${project.namespace}/${project.name}`} icon={project.isPrivate ? <Lock size={15} /> : <Box size={15} />} drill="hover"> | |
| 608 | + | {project.title ?? project.name} | |
| 609 | + | </SidebarLink> | |
| 610 | + | ); | |
| 611 | + | return ( | |
| 612 | + | <SidebarGroup | |
| 613 | + | title="Projects" | |
| 614 | + | action={ | |
| 615 | + | <Link to={`/new?workspace=${slug}`} aria-label="New project" className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"> | |
| 616 | + | <Plus size={13} /> | |
| 617 | + | </Link> | |
| 618 | + | } | |
| 619 | + | > | |
| 620 | + | <SidebarSubhead>Pinned</SidebarSubhead> | |
| 621 | + | {pinned.length === 0 ? ( | |
| 622 | + | <p className="px-2 pb-1 text-xs text-faint">Pin projects to keep them here.</p> | |
| 623 | + | ) : ( | |
| 624 | + | <ul aria-label="Pinned projects" className="space-y-px"> | |
| 625 | + | {pinned.map((project, index) => ( | |
| 626 | + | <li | |
| 627 | + | key={project.name} | |
| 628 | + | draggable={pinned.length > 1} | |
| 629 | + | onDragStart={(event) => { | |
| 630 | + | setDragging(index); | |
| 631 | + | event.dataTransfer.effectAllowed = "move"; | |
| 632 | + | }} | |
| 633 | + | onDragOver={(event) => { | |
| 634 | + | if (dragging != null) event.preventDefault(); | |
| 635 | + | }} | |
| 636 | + | onDrop={(event) => { | |
| 637 | + | event.preventDefault(); | |
| 638 | + | if (dragging != null && dragging !== index) move(dragging, index - dragging); | |
| 639 | + | setDragging(null); | |
| 640 | + | }} | |
| 641 | + | onDragEnd={() => setDragging(null)} | |
| 642 | + | onKeyDown={(event) => { | |
| 643 | + | if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return; | |
| 644 | + | event.preventDefault(); | |
| 645 | + | move(index, event.key === "ArrowUp" ? -1 : 1); | |
| 646 | + | }} | |
| 647 | + | title={pinned.length > 1 ? "Drag, or Alt and an arrow key, to reorder" : undefined} | |
| 648 | + | className={`group/pin relative ${dragging === index ? "opacity-50" : ""}`} | |
| 649 | + | > | |
| 650 | + | {row(project)} | |
| 651 | + | {pinned.length > 1 && ( | |
| 652 | + | <GripVertical | |
| 653 | + | size={12} | |
| 654 | + | aria-hidden="true" | |
| 655 | + | className="pointer-events-none absolute top-1/2 -left-1.5 -translate-y-1/2 text-faint opacity-0 transition-opacity group-hover/pin:opacity-100" | |
| 656 | + | /> | |
| 657 | + | )} | |
| 658 | + | </li> | |
| 659 | + | ))} | |
| 660 | + | </ul> | |
| 661 | + | )} | |
| 662 | + | {recent.length > 0 && ( | |
| 663 | + | <> | |
| 664 | + | <SidebarSubhead>Recent</SidebarSubhead> | |
| 665 | + | {recent.map((project) => ( | |
| 666 | + | <div key={`${project.namespace}/${project.name}`}>{row(project)}</div> | |
| 667 | + | ))} | |
| 668 | + | </> | |
| 669 | + | )} | |
| 670 | + | <div className="pt-1"> | |
| 671 | + | <SidebarLink to={`/${slug}/-/projects`} icon={<LayoutGrid size={15} />} count={shell.repos.length}> | |
| 672 | + | All projects | |
| 673 | + | </SidebarLink> | |
| 674 | + | </div> | |
| 675 | + | </SidebarGroup> | |
| 676 | + | ); | |
| 677 | + | } | |
| 678 | + | ||
| 553 | 679 | /** | |
| 554 | 680 | * The main list: where you go, the projects, what the workspace builds and | |
| 555 | 681 | * runs with across them, then its usage, support and settings. | |
| 556 | 682 | */ | |
| 557 | 683 | function MainMenu({ user, shell }: { user: User | null; shell: ShellData }) { | |
| 558 | 684 | const ws = shell.workspace; | |
| 559 | − | const active = shell.repo; | |
| 560 | − | // The repository being looked at is listed even when it is someone else's. | |
| 561 | − | const listed = | |
| 562 | − | active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name) | |
| 563 | − | ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos] | |
| 564 | − | : shell.repos; | |
| 565 | 685 | // A visitor browses: no workspace, no projects of their own. | |
| 566 | 686 | if (!user) { | |
| 567 | 687 | return ( | |
| 578 | 698 | } | |
| 579 | 699 | return ( | |
| 580 | 700 | <nav aria-label="g1t" className={PANEL}> | |
| 701 | + | {/* Everything here is the workspace the switcher names: its home first. | |
| 702 | + | The workspace's own page is the switcher's; Explore, all of g1t, | |
| 703 | + | is in the top bar. */} | |
| 581 | 704 | <div className="mt-3 space-y-px"> | |
| 582 | 705 | <SidebarLink to="/" end icon={<House size={15} />}> | |
| 583 | 706 | Mission control | |
| 584 | 707 | </SidebarLink> | |
| 585 | − | {ws && ( | |
| 586 | − | <SidebarLink to={`/${ws.slug}`} end icon={<LayoutGrid size={15} />}> | |
| 587 | − | Overview | |
| 588 | − | </SidebarLink> | |
| 589 | − | )} | |
| 590 | − | <SidebarLink to="/explore" icon={<Compass size={15} />}> | |
| 591 | − | Explore | |
| 592 | − | </SidebarLink> | |
| 593 | 708 | </div> | |
| 594 | 709 | ||
| 595 | 710 | <Rule /> | |
| 596 | − | <SidebarGroup | |
| 597 | − | title="Projects" | |
| 598 | − | action={ | |
| 599 | − | ws && ( | |
| 600 | − | <Link | |
| 601 | − | to={`/new?workspace=${ws.slug}`} | |
| 602 | − | aria-label="New project" | |
| 603 | − | className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg" | |
| 604 | − | > | |
| 605 | − | <Plus size={13} /> | |
| 606 | − | </Link> | |
| 607 | − | ) | |
| 608 | − | } | |
| 609 | − | > | |
| 610 | − | {listed.length === 0 && <p className="px-2 py-1 text-xs text-faint">None yet.</p>} | |
| 611 | − | {listed.map((repo) => ( | |
| 612 | − | <SidebarLink | |
| 613 | − | key={`${repo.namespace}/${repo.name}`} | |
| 614 | − | to={`/${repo.namespace}/${repo.name}`} | |
| 615 | − | icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />} | |
| 616 | − | drill="hover" | |
| 617 | − | > | |
| 618 | − | {repo.namespace !== ws?.slug && <span className="font-mono text-faint">{repo.namespace}/</span>} | |
| 619 | − | {"title" in repo && repo.title ? repo.title : repo.name} | |
| 620 | − | </SidebarLink> | |
| 621 | − | ))} | |
| 622 | − | </SidebarGroup> | |
| 711 | + | {ws ? ( | |
| 712 | + | <SidebarProjects slug={ws.slug} shell={shell} /> | |
| 713 | + | ) : ( | |
| 714 | + | <SidebarGroup title="Projects"> | |
| 715 | + | <p className="px-2 py-1 text-xs text-faint">None yet.</p> | |
| 716 | + | </SidebarGroup> | |
| 717 | + | )} | |
| 623 | 718 | {(shell.shared ?? []).length > 0 && ( | |
| 624 | 719 | <SidebarGroup title="Shared with you" className="mt-3"> | |
| 625 | 720 | {(shell.shared ?? []).map((repo) => ( | |
| 639 | 734 | {ws && ( | |
| 640 | 735 | <> | |
| 641 | 736 | <Rule /> | |
| 642 | − | <div className="space-y-px"> | |
| 737 | + | <SidebarGroup title="Workspace"> | |
| 643 | 738 | <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}> | |
| 644 | 739 | Agent fleet | |
| 645 | 740 | </SidebarLink> | |
| 655 | 750 | <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />}> | |
| 656 | 751 | Packages | |
| 657 | 752 | </SidebarLink> | |
| 658 | − | {roadmapIn("Workspace").filter((item) => item.key !== "teams").map((item) => ( | |
| 753 | + | {roadmapIn("Workspace").filter((item) => item.key !== "teams" && item.key !== "insights").map((item) => ( | |
| 659 | 754 | <SidebarSoonLink | |
| 660 | 755 | key={item.key} | |
| 661 | 756 | to={`/${ws.slug}/-/soon/${item.key}`} | |
| 665 | 760 | {item.title === "Board" ? "Boards" : item.title} | |
| 666 | 761 | </SidebarSoonLink> | |
| 667 | 762 | ))} | |
| 668 | − | </div> | |
| 763 | + | </SidebarGroup> | |
| 669 | 764 | ||
| 670 | 765 | <Rule /> | |
| 671 | 766 | <div className="space-y-px"> | |
| 672 | 767 | {/* Who belongs, for every member to see; owners invite and manage there. */} | |
| 673 | 768 | <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}> | |
| 674 | − | Members | |
| 769 | + | People | |
| 675 | 770 | </SidebarLink> | |
| 676 | 771 | {roadmapIn("Workspace") | |
| 677 | 772 | .filter((item) => item.key === "teams") | |
| 678 | 773 | .map((item) => ( | |
| 679 | − | <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/soon/${item.key}`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}> | |
| 774 | + | <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/teams`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}> | |
| 680 | 775 | {item.title} | |
| 681 | 776 | </SidebarSoonLink> | |
| 682 | 777 | ))} | |
| 1131 | 1226 | const back = !user | |
| 1132 | 1227 | ? { to: "/explore", label: "Explore" } | |
| 1133 | 1228 | : home | |
| 1134 | − | ? { to: `/${home.slug}`, label: "All projects" } | |
| 1229 | + | ? { to: `/${home.slug}/-/projects`, label: "All projects" } | |
| 1135 | 1230 | : { to: "/", label: "Mission control" }; | |
| 1136 | 1231 | trail.push({ | |
| 1137 | 1232 | key, | |
| 1219 | 1314 | code: "Files", | |
| 1220 | 1315 | secrets: "Secrets and variables", | |
| 1221 | 1316 | settings: "Settings", | |
| 1222 | − | people: "Members", | |
| 1317 | + | people: "People", | |
| 1318 | + | projects: "Projects", | |
| 1319 | + | teams: "Teams", | |
| 1223 | 1320 | tokens: "Access tokens", | |
| 1224 | 1321 | usage: "Usage", | |
| 1225 | 1322 | billing: "Billing and plans", | |
| 1386 | 1483 | for (const membership of user.workspaces ?? []) { | |
| 1387 | 1484 | commands.push( | |
| 1388 | 1485 | { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> }, | |
| 1486 | + | { label: "All projects", hint: membership.slug, to: `/${membership.slug}/-/projects`, icon: <LayoutGrid size={15} /> }, | |
| 1487 | + | { label: "People", hint: membership.slug, to: `/${membership.slug}/-/people`, icon: <Users size={15} /> }, | |
| 1389 | 1488 | { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> }, | |
| 1390 | 1489 | { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> }, | |
| 1391 | 1490 | { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> }, | |
| 1440 | 1539 | } | |
| 1441 | 1540 | ||
| 1442 | 1541 | /** | |
| 1542 | + | * The page while the next one loads: after a moment it dims, under the | |
| 1543 | + | * bar below, so it is plainly on its way out rather than frozen, and the | |
| 1544 | + | * next page fades up from there. Sending a form does not dim it: the | |
| 1545 | + | * form's button says it is working. Spread on the element holding the page. | |
| 1546 | + | */ | |
| 1547 | + | export function useLeaving() { | |
| 1548 | + | const navigation = useNavigation(); | |
| 1549 | + | const method = navigation.formMethod?.toUpperCase(); | |
| 1550 | + | const leaving = navigation.state === "loading" && (method == null || method === "GET"); | |
| 1551 | + | return { | |
| 1552 | + | "aria-busy": leaving || undefined, | |
| 1553 | + | className: `transition-opacity motion-reduce:transition-none ${leaving ? "opacity-60 delay-200 duration-300" : "duration-150"}`, | |
| 1554 | + | }; | |
| 1555 | + | } | |
| 1556 | + | ||
| 1557 | + | /** | |
| 1443 | 1558 | * A bar across the top of the page while the next one loads, as GitHub | |
| 1444 | 1559 | * has: it appears at once, creeps towards the end while waiting, then fills | |
| 1445 | 1560 | * and fades when the page arrives. | |
| 1520 | 1635 | else setTimeout(load, 1500); | |
| 1521 | 1636 | }, [user]); | |
| 1522 | 1637 | usePaletteShortcut(() => setPalette((open) => !open)); | |
| 1638 | + | const leaving = useLeaving(); | |
| 1523 | 1639 | ||
| 1524 | 1640 | return ( | |
| 1525 | 1641 | <div className="min-h-screen"> | |
| 1566 | 1682 | </kbd> | |
| 1567 | 1683 | </Form> | |
| 1568 | 1684 | <div className="ml-auto flex items-center gap-1.5 md:ml-0"> | |
| 1685 | + | {/* All of g1t's public projects: not any one workspace's, so here, not in the sidebar. */} | |
| 1686 | + | <NavLink | |
| 1687 | + | to="/explore" | |
| 1688 | + | prefetch="intent" | |
| 1689 | + | aria-label="Explore" | |
| 1690 | + | className={({ isActive }) => | |
| 1691 | + | `flex h-9 items-center gap-1.5 rounded-md px-2 text-sm transition-colors hover:bg-raised hover:text-fg sm:px-2.5 ${isActive ? "text-fg" : "text-muted"}` | |
| 1692 | + | } | |
| 1693 | + | > | |
| 1694 | + | <Compass size={16} className="sm:hidden" /> | |
| 1695 | + | <span className="hidden sm:inline">Explore</span> | |
| 1696 | + | </NavLink> | |
| 1569 | 1697 | <a | |
| 1570 | 1698 | href="https://docs.g1t.sh/" | |
| 1571 | 1699 | className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block" | |
| 1630 | 1758 | </div> | |
| 1631 | 1759 | </header> | |
| 1632 | 1760 | {banner} | |
| 1633 | − | <main className="min-w-0 grow">{children}</main> | |
| 1761 | + | <main {...leaving} className={`min-w-0 grow ${leaving.className}`}> | |
| 1762 | + | {children} | |
| 1763 | + | </main> | |
| 1634 | 1764 | </div> | |
| 1635 | 1765 | <CommandPalette | |
| 1636 | 1766 | open={palette} |
| 1 | − | import type { ComponentProps } from "react"; | |
| 1 | + | import type { ComponentProps, ReactNode } from "react"; | |
| 2 | 2 | ||
| 3 | 3 | import { cn } from "../../lib/cn"; | |
| 4 | 4 | ||
| 5 | 5 | // The shape of something still loading: a quiet raised block that pulses | |
| 6 | 6 | // (still for people who ask for less motion). Give it the size of what it | |
| 7 | 7 | // stands for, so nothing moves when the real thing arrives. Hidden from | |
| 8 | − | // screen readers; the region it sits in says it is busy (`aria-busy`). | |
| 8 | + | // screen readers; the region it sits in says it is busy (`aria-busy`), | |
| 9 | + | // which `Loading` does, with a "Loading…" for them to hear. | |
| 10 | + | // | |
| 11 | + | // The pieces below are the app's common shapes, each the height of the | |
| 12 | + | // real thing: compose a page's outline from them rather than drawing one. | |
| 9 | 13 | ||
| 10 | 14 | /** One block. Size it with classes: `h-3 w-24`, `size-8 rounded-full`. */ | |
| 11 | 15 | export function Skeleton({ className, ...props }: ComponentProps<"span">) { | |
| 19 | 23 | } | |
| 20 | 24 | ||
| 21 | 25 | /** | |
| 26 | + | * A bar standing in for one line of text, as tall as the line it sits in | |
| 27 | + | * (`1lh`), so a row keeps its height when the words arrive. | |
| 28 | + | */ | |
| 29 | + | export function SkeletonLine({ className, barClassName, width }: { className?: string; barClassName?: string; width?: string }) { | |
| 30 | + | return ( | |
| 31 | + | <span aria-hidden="true" className={cn("flex h-[1lh] items-center", className)}> | |
| 32 | + | <Skeleton className={cn("h-3 w-full", barClassName)} style={width ? { width } : undefined} /> | |
| 33 | + | </span> | |
| 34 | + | ); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /** | |
| 38 | + | * A region whose content is on its way: busy for assistive technology, | |
| 39 | + | * with "Loading…" said once. The skeleton inside is hidden from them. | |
| 40 | + | */ | |
| 41 | + | export function Loading({ | |
| 42 | + | label = "Loading…", | |
| 43 | + | className, | |
| 44 | + | children, | |
| 45 | + | ...props | |
| 46 | + | }: { label?: string; children: ReactNode } & ComponentProps<"div">) { | |
| 47 | + | return ( | |
| 48 | + | <div aria-busy="true" className={className} {...props}> | |
| 49 | + | <span role="status" className="sr-only"> | |
| 50 | + | {label} | |
| 51 | + | </span> | |
| 52 | + | {children} | |
| 53 | + | </div> | |
| 54 | + | ); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** | |
| 22 | 58 | * Lines of text: `lines` bars `h-3` apart by `gap`, the last one shorter, | |
| 23 | 59 | * as a paragraph or a list's titles would sit. | |
| 24 | 60 | */ | |
| 42 | 78 | {Array.from({ length: rows }, (_, index) => ( | |
| 43 | 79 | <div key={index} className={cn("flex h-10 items-center gap-3 px-3", rowClassName)}> | |
| 44 | 80 | <Skeleton className="size-4 shrink-0 rounded-full" /> | |
| 45 | − | <Skeleton className="h-3 grow" style={{ maxWidth: `${70 - ((index * 17) % 30)}%` }} /> | |
| 81 | + | <Skeleton className="h-3 grow" style={{ maxWidth: varied(index) }} /> | |
| 46 | 82 | <Skeleton className="h-3 w-12 shrink-0" /> | |
| 47 | 83 | </div> | |
| 48 | 84 | ))} | |
| 49 | 85 | </div> | |
| 50 | 86 | ); | |
| 51 | 87 | } | |
| 88 | + | ||
| 89 | + | /** A width for row `index` that varies, so a list of bars does not look ruled. */ | |
| 90 | + | function varied(index: number, widest = 70, spread = 30) { | |
| 91 | + | return `${widest - ((index * 17) % spread)}%`; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** | |
| 95 | + | * Rows of a table: `columns` cells across, the first wide, the rest as | |
| 96 | + | * `cellClassName` sizes them, in rows as tall as text rows (`py-2`). | |
| 97 | + | */ | |
| 98 | + | export function SkeletonTableRows({ | |
| 99 | + | rows = 3, | |
| 100 | + | columns = 3, | |
| 101 | + | className, | |
| 102 | + | rowClassName, | |
| 103 | + | }: { | |
| 104 | + | rows?: number; | |
| 105 | + | columns?: number; | |
| 106 | + | className?: string; | |
| 107 | + | rowClassName?: string; | |
| 108 | + | }) { | |
| 109 | + | return ( | |
| 110 | + | <div aria-hidden="true" className={cn("divide-y divide-line text-sm", className)}> | |
| 111 | + | {Array.from({ length: rows }, (_, row) => ( | |
| 112 | + | <div key={row} className={cn("flex items-center gap-4 px-4 py-2", rowClassName)}> | |
| 113 | + | <SkeletonLine className="grow" width={varied(row)} /> | |
| 114 | + | {Array.from({ length: columns - 1 }, (_, column) => ( | |
| 115 | + | <SkeletonLine key={column} className="w-16 shrink-0" /> | |
| 116 | + | ))} | |
| 117 | + | </div> | |
| 118 | + | ))} | |
| 119 | + | </div> | |
| 120 | + | ); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /** | |
| 124 | + | * An item of an issue's or pull request's timeline (components/work.tsx | |
| 125 | + | * `TimelineItem`): the avatar beside a card with its header and `lines` | |
| 126 | + | * of body. | |
| 127 | + | */ | |
| 128 | + | export function SkeletonTimelineItem({ lines = 2, className }: { lines?: number; className?: string }) { | |
| 129 | + | return ( | |
| 130 | + | <div aria-hidden="true" className={cn("flex gap-3", className)}> | |
| 131 | + | <Skeleton className="mt-1 hidden size-8 shrink-0 rounded-full sm:block" /> | |
| 132 | + | <div className="min-w-0 grow overflow-hidden rounded-xl border border-line bg-surface"> | |
| 133 | + | <div className="flex items-center gap-2 border-b border-line bg-raised/40 px-4 py-2 text-sm"> | |
| 134 | + | <SkeletonLine className="w-20" /> | |
| 135 | + | <SkeletonLine className="w-32" /> | |
| 136 | + | </div> | |
| 137 | + | {lines > 0 && <SkeletonText lines={lines} className="px-4 py-4" />} | |
| 138 | + | </div> | |
| 139 | + | </div> | |
| 140 | + | ); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /** A card: a title and `lines` of text, in the app's bordered box. */ | |
| 144 | + | export function SkeletonCard({ lines = 2, className }: { lines?: number; className?: string }) { | |
| 145 | + | return ( | |
| 146 | + | <div aria-hidden="true" className={cn("rounded-xl border border-line bg-surface p-4", className)}> | |
| 147 | + | <SkeletonLine className="w-1/3 text-sm" /> | |
| 148 | + | {lines > 0 && <SkeletonText lines={lines} className="mt-3" />} | |
| 149 | + | </div> | |
| 150 | + | ); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /** A sidebar section: its small heading and `rows` lines under it. */ | |
| 154 | + | export function SkeletonSidebarSection({ rows = 2, className }: { rows?: number; className?: string }) { | |
| 155 | + | return ( | |
| 156 | + | <div aria-hidden="true" className={cn("text-sm", className)}> | |
| 157 | + | <SkeletonLine className="w-24" /> | |
| 158 | + | <div className="mt-2 space-y-1.5"> | |
| 159 | + | {Array.from({ length: rows }, (_, index) => ( | |
| 160 | + | <div key={index} className="flex items-center gap-2"> | |
| 161 | + | <Skeleton className="size-5 shrink-0 rounded-full" /> | |
| 162 | + | <SkeletonLine className="grow" barClassName="w-28" /> | |
| 163 | + | </div> | |
| 164 | + | ))} | |
| 165 | + | </div> | |
| 166 | + | </div> | |
| 167 | + | ); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /** | |
| 171 | + | * A stat tile, as mission control's tiles sit in their grid: its label, | |
| 172 | + | * the number, and the line under it, each the height of its text. | |
| 173 | + | */ | |
| 174 | + | export function SkeletonStat({ className }: { className?: string }) { | |
| 175 | + | return ( | |
| 176 | + | <div aria-hidden="true" className={cn("bg-surface px-4 py-3.5 sm:px-5 sm:py-4", className)}> | |
| 177 | + | <SkeletonLine className="w-16 text-xs" /> | |
| 178 | + | <SkeletonLine className="mt-1 w-12 text-2xl sm:text-[1.75rem]" barClassName="h-6" /> | |
| 179 | + | <SkeletonLine className="mt-0.5 w-24 text-xs" /> | |
| 180 | + | </div> | |
| 181 | + | ); | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /** | |
| 185 | + | * Lines of code, numbered, as `CodeLines` and a README's code blocks set | |
| 186 | + | * them: `lines` rows of the monospace line height. | |
| 187 | + | */ | |
| 188 | + | export function SkeletonCode({ lines = 6, className }: { lines?: number; className?: string }) { | |
| 189 | + | return ( | |
| 190 | + | <div aria-hidden="true" className={cn("py-2 font-mono text-[0.8125rem] leading-6", className)}> | |
| 191 | + | {Array.from({ length: lines }, (_, index) => ( | |
| 192 | + | <div key={index} className="flex items-center gap-4 px-4"> | |
| 193 | + | <SkeletonLine className="w-6 shrink-0" /> | |
| 194 | + | <SkeletonLine className="grow" width={varied(index, 80, 50)} /> | |
| 195 | + | </div> | |
| 196 | + | ))} | |
| 197 | + | </div> | |
| 198 | + | ); | |
| 199 | + | } |
| 1 | + | /** | |
| 2 | + | * The shapes of a workspace's tabs while they load: the same layout as | |
| 3 | + | * the real thing, so nothing moves when it arrives (components/ui/skeleton.tsx). | |
| 4 | + | */ | |
| 5 | + | import type { WorkspaceTabKey } from "../lib/workspace-nav"; | |
| 6 | + | import { Skeleton, SkeletonRows, SkeletonText } from "./ui/skeleton"; | |
| 7 | + | ||
| 8 | + | /** A project card, as the Overview and the Projects grid show them. */ | |
| 9 | + | export function ProjectCardSkeleton() { | |
| 10 | + | return ( | |
| 11 | + | <li aria-hidden="true" className="flex flex-col rounded-2xl border border-line bg-surface p-5"> | |
| 12 | + | <div className="flex items-start gap-3"> | |
| 13 | + | <Skeleton className="size-9 shrink-0 rounded-lg" /> | |
| 14 | + | <div className="grow space-y-2 pt-1"> | |
| 15 | + | <Skeleton className="h-3.5 w-32" /> | |
| 16 | + | <Skeleton className="h-3 w-48" /> | |
| 17 | + | </div> | |
| 18 | + | </div> | |
| 19 | + | <div className="mt-8 flex gap-4 border-t border-line pt-3"> | |
| 20 | + | <Skeleton className="h-3 w-12" /> | |
| 21 | + | <Skeleton className="h-3 w-8" /> | |
| 22 | + | <Skeleton className="h-3 w-8" /> | |
| 23 | + | </div> | |
| 24 | + | </li> | |
| 25 | + | ); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /** The Projects page's rows. */ | |
| 29 | + | export function ProjectRowsSkeleton({ rows = 8 }: { rows?: number }) { | |
| 30 | + | return ( | |
| 31 | + | <div aria-hidden="true" className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 32 | + | {Array.from({ length: rows }, (_, index) => ( | |
| 33 | + | <div key={index} className="flex items-center gap-3 border-b border-line px-4 py-3.5 last:border-b-0"> | |
| 34 | + | <Skeleton className="size-8 shrink-0 rounded-md" /> | |
| 35 | + | <div className="grow space-y-2"> | |
| 36 | + | <Skeleton className="h-3.5" style={{ width: `${30 + ((index * 13) % 25)}%` }} /> | |
| 37 | + | <Skeleton className="h-3" style={{ width: `${45 + ((index * 7) % 30)}%` }} /> | |
| 38 | + | </div> | |
| 39 | + | <Skeleton className="hidden h-3 w-16 sm:block" /> | |
| 40 | + | <Skeleton className="size-8 shrink-0 rounded-md" /> | |
| 41 | + | </div> | |
| 42 | + | ))} | |
| 43 | + | </div> | |
| 44 | + | ); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /** The Projects page: its search and filters, then rows. */ | |
| 48 | + | export function ProjectsSkeleton() { | |
| 49 | + | return ( | |
| 50 | + | <div aria-busy="true" aria-label="Loading projects" className="space-y-4"> | |
| 51 | + | <div className="flex flex-wrap gap-2"> | |
| 52 | + | <Skeleton className="h-9 w-full rounded-md sm:w-72" /> | |
| 53 | + | <Skeleton className="h-9 w-28 rounded-md" /> | |
| 54 | + | <Skeleton className="h-9 w-28 rounded-md" /> | |
| 55 | + | <Skeleton className="h-9 w-36 rounded-md" /> | |
| 56 | + | </div> | |
| 57 | + | <ProjectRowsSkeleton /> | |
| 58 | + | </div> | |
| 59 | + | ); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** The Overview: pinned cards, then recent ones beside the aside. */ | |
| 63 | + | export function OverviewSkeleton() { | |
| 64 | + | return ( | |
| 65 | + | <div aria-busy="true" aria-label="Loading" className="grid gap-10 lg:grid-cols-[1fr_18rem]"> | |
| 66 | + | <div className="space-y-10"> | |
| 67 | + | <div> | |
| 68 | + | <Skeleton className="h-3.5 w-20" /> | |
| 69 | + | <ul className="mt-3 grid gap-4 sm:grid-cols-2"> | |
| 70 | + | {Array.from({ length: 4 }, (_, index) => ( | |
| 71 | + | <ProjectCardSkeleton key={index} /> | |
| 72 | + | ))} | |
| 73 | + | </ul> | |
| 74 | + | </div> | |
| 75 | + | <div> | |
| 76 | + | <Skeleton className="h-3.5 w-28" /> | |
| 77 | + | <SkeletonRows rows={4} className="mt-3 rounded-xl border border-line bg-surface" /> | |
| 78 | + | </div> | |
| 79 | + | </div> | |
| 80 | + | <div className="space-y-4"> | |
| 81 | + | <Skeleton className="h-40 rounded-xl" /> | |
| 82 | + | <SkeletonText lines={4} /> | |
| 83 | + | </div> | |
| 84 | + | </div> | |
| 85 | + | ); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /** Whichever tab is on its way. */ | |
| 89 | + | export function WorkspaceTabSkeleton({ tab }: { tab: WorkspaceTabKey }) { | |
| 90 | + | if (tab === "overview") return <OverviewSkeleton />; | |
| 91 | + | if (tab === "projects") return <ProjectsSkeleton />; | |
| 92 | + | return ( | |
| 93 | + | <div aria-busy="true" aria-label="Loading" className="space-y-4"> | |
| 94 | + | <Skeleton className="h-3.5 w-2/3 max-w-xl" /> | |
| 95 | + | <SkeletonRows rows={6} className="rounded-xl border border-line bg-surface" /> | |
| 96 | + | </div> | |
| 97 | + | ); | |
| 98 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { MAX_RECENT, movedPin, pinFromForm, recentWith } from "./pins.ts"; | |
| 5 | + | ||
| 6 | + | const p = (name: string, namespace = "acme") => ({ namespace, name, isPrivate: false }); | |
| 7 | + | const names = (list: { name: string }[]) => list.map((project) => project.name); | |
| 8 | + | ||
| 9 | + | test("the project being looked at leads recent, once", () => { | |
| 10 | + | assert.deepEqual(names(recentWith([p("a"), p("b")], [], p("c"), "acme")), ["c", "a", "b"]); | |
| 11 | + | assert.deepEqual(names(recentWith([p("a"), p("b")], [], p("b"), "acme")), ["b", "a"]); | |
| 12 | + | }); | |
| 13 | + | ||
| 14 | + | test("pinned projects are never also recent", () => { | |
| 15 | + | assert.deepEqual(names(recentWith([p("a"), p("b")], [p("a")], p("b"), "acme")), ["b"]); | |
| 16 | + | assert.deepEqual(names(recentWith([p("a")], [p("c")], p("c"), "acme")), ["a"]); | |
| 17 | + | }); | |
| 18 | + | ||
| 19 | + | test("another workspace's project is not this one's recent", () => { | |
| 20 | + | assert.deepEqual(names(recentWith([p("a")], [], p("x", "other"), "acme")), ["a"]); | |
| 21 | + | assert.deepEqual(names(recentWith([p("a")], [], p("x"), null)), ["a"]); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("recent holds a few", () => { | |
| 25 | + | const many = Array.from({ length: 9 }, (_, i) => p(`r${i}`)); | |
| 26 | + | assert.equal(recentWith(many, [], p("now"), "acme").length, MAX_RECENT); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("a pin form says what to change", () => { | |
| 30 | + | const form = (entries: [string, string][]) => { | |
| 31 | + | const data = new FormData(); | |
| 32 | + | for (const [key, value] of entries) data.append(key, value); | |
| 33 | + | return data; | |
| 34 | + | }; | |
| 35 | + | assert.deepEqual(pinFromForm(form([["intent", "pin"], ["slug", " Web "]])), { intent: "pin", slug: "web", position: null }); | |
| 36 | + | assert.deepEqual(pinFromForm(form([["intent", "pin"], ["slug", "web"], ["position", "2"]])), { intent: "pin", slug: "web", position: 2 }); | |
| 37 | + | assert.deepEqual(pinFromForm(form([["intent", "unpin"], ["slug", "web"]])), { intent: "unpin", slug: "web" }); | |
| 38 | + | assert.deepEqual(pinFromForm(form([["intent", "reorder"], ["slug", "b"], ["slug", "a"]])), { intent: "reorder", slugs: ["b", "a"] }); | |
| 39 | + | assert.equal(pinFromForm(form([["intent", "reorder"], ["slug", "a"], ["slug", "a"]])), null); | |
| 40 | + | assert.equal(pinFromForm(form([["intent", "pin"]])), null); | |
| 41 | + | assert.equal(pinFromForm(form([["intent", "star"], ["slug", "web"]])), null); | |
| 42 | + | }); | |
| 43 | + | ||
| 44 | + | test("a pin moves up or down within the list", () => { | |
| 45 | + | assert.deepEqual(movedPin(["a", "b", "c"], 2, -1), ["a", "c", "b"]); | |
| 46 | + | assert.deepEqual(movedPin(["a", "b", "c"], 0, 1), ["b", "a", "c"]); | |
| 47 | + | assert.equal(movedPin(["a", "b", "c"], 0, -1), null); | |
| 48 | + | assert.equal(movedPin(["a", "b", "c"], 2, 1), null); | |
| 49 | + | }); |
| 1 | + | /** | |
| 2 | + | * Pinned and recent projects in the sidebar: the viewer's pins in the | |
| 3 | + | * workspace (up to eight, in their order), then what they opened last that | |
| 4 | + | * they have not pinned (up to five), then All projects. The projects | |
| 5 | + | * service keeps both; opening a project records it after the page is sent. | |
| 6 | + | */ | |
| 7 | + | ||
| 8 | + | /** A project as the sidebar lists it. */ | |
| 9 | + | export type ShortcutProject = { namespace: string; name: string; title?: string; isPrivate: boolean }; | |
| 10 | + | ||
| 11 | + | export const MAX_PINS = 8; | |
| 12 | + | export const MAX_RECENT = 5; | |
| 13 | + | ||
| 14 | + | const same = (a: { namespace: string; name: string }, b: { namespace: string; name: string }) => | |
| 15 | + | a.namespace.toLowerCase() === b.namespace.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase(); | |
| 16 | + | ||
| 17 | + | /** | |
| 18 | + | * Recent, with the project being looked at first when it is the | |
| 19 | + | * workspace's and not pinned: it was opened just now, before the projects | |
| 20 | + | * service has heard. | |
| 21 | + | */ | |
| 22 | + | export function recentWith( | |
| 23 | + | recent: ShortcutProject[], | |
| 24 | + | pinned: ShortcutProject[], | |
| 25 | + | current: ShortcutProject | null, | |
| 26 | + | workspace: string | null, | |
| 27 | + | ): ShortcutProject[] { | |
| 28 | + | const here = current && workspace && current.namespace.toLowerCase() === workspace.toLowerCase() && !pinned.some((p) => same(p, current)) ? current : null; | |
| 29 | + | const rest = recent.filter((project) => !pinned.some((p) => same(p, project)) && !(here && same(project, here))); | |
| 30 | + | return [...(here ? [here] : []), ...rest].slice(0, MAX_RECENT); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** One change to the viewer's pins, as a form posts it. */ | |
| 34 | + | export type PinChange = | |
| 35 | + | | { intent: "pin"; slug: string; position: number | null } | |
| 36 | + | | { intent: "unpin"; slug: string } | |
| 37 | + | | { intent: "reorder"; slugs: string[] }; | |
| 38 | + | ||
| 39 | + | /** What a pin form asks for; null when it asks for nothing that makes sense. */ | |
| 40 | + | export function pinFromForm(form: FormData): PinChange | null { | |
| 41 | + | const intent = form.get("intent"); | |
| 42 | + | const slug = String(form.get("slug") ?? "").trim().toLowerCase(); | |
| 43 | + | if (intent === "reorder") { | |
| 44 | + | const slugs = form.getAll("slug").map((value) => String(value).trim().toLowerCase()).filter(Boolean); | |
| 45 | + | return slugs.length > 0 && new Set(slugs).size === slugs.length ? { intent, slugs } : null; | |
| 46 | + | } | |
| 47 | + | if (!slug) return null; | |
| 48 | + | if (intent === "unpin") return { intent, slug }; | |
| 49 | + | if (intent !== "pin") return null; | |
| 50 | + | const position = Number.parseInt(String(form.get("position") ?? ""), 10); | |
| 51 | + | return { intent, slug, position: Number.isFinite(position) && position >= 0 ? position : null }; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /** | |
| 55 | + | * The pins in a new order after moving the one at `from` by `by` places | |
| 56 | + | * (-1 up, 1 down); null when it cannot move that way. | |
| 57 | + | */ | |
| 58 | + | export function movedPin<T>(pins: T[], from: number, by: number): T[] | null { | |
| 59 | + | const to = from + by; | |
| 60 | + | if (from < 0 || from >= pins.length || to < 0 || to >= pins.length || by === 0) return null; | |
| 61 | + | const next = [...pins]; | |
| 62 | + | const [moving] = next.splice(from, 1); | |
| 63 | + | next.splice(to, 0, moving!); | |
| 64 | + | return next; | |
| 65 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | DEFAULT_QUERY, | |
| 6 | + | type Listed, | |
| 7 | + | PAGE_SIZE, | |
| 8 | + | facetsOf, | |
| 9 | + | filterProjects, | |
| 10 | + | isFiltered, | |
| 11 | + | nextRow, | |
| 12 | + | pageOf, | |
| 13 | + | projectQueryString, | |
| 14 | + | readProjectQuery, | |
| 15 | + | } from "./project-list.ts"; | |
| 16 | + | ||
| 17 | + | function project(slug: string, more: Partial<Listed> = {}): Listed { | |
| 18 | + | return { | |
| 19 | + | id: `prj_${slug}`, | |
| 20 | + | slug, | |
| 21 | + | name: slug, | |
| 22 | + | description: null, | |
| 23 | + | private: false, | |
| 24 | + | archived: false, | |
| 25 | + | kind: "app", | |
| 26 | + | ecosystem: null, | |
| 27 | + | updatedAt: "2026-10-01T00:00:00.000Z", | |
| 28 | + | pushedAt: null, | |
| 29 | + | activity: 0, | |
| 30 | + | deploying: false, | |
| 31 | + | ...more, | |
| 32 | + | }; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | const q = (search: string) => readProjectQuery(new URLSearchParams(search)); | |
| 36 | + | ||
| 37 | + | test("an empty address is the default list", () => { | |
| 38 | + | assert.deepEqual(q(""), DEFAULT_QUERY); | |
| 39 | + | assert.equal(projectQueryString(DEFAULT_QUERY), ""); | |
| 40 | + | assert.equal(isFiltered(DEFAULT_QUERY), false); | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("what the address says is read, and nonsense is the default", () => { | |
| 44 | + | const query = q("q=%20api%20&visibility=private&kind=library&language=Rust&deployments=on&archived=only&sort=active&view=grid&page=3"); | |
| 45 | + | assert.deepEqual(query, { | |
| 46 | + | q: "api", | |
| 47 | + | visibility: "private", | |
| 48 | + | kind: "library", | |
| 49 | + | language: "Rust", | |
| 50 | + | deployments: true, | |
| 51 | + | archived: "only", | |
| 52 | + | sort: "active", | |
| 53 | + | view: "grid", | |
| 54 | + | page: 3, | |
| 55 | + | }); | |
| 56 | + | assert.deepEqual(q("visibility=secret&sort=random&page=-2&view=table"), DEFAULT_QUERY); | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | test("the address round-trips, and a change goes back to page one", () => { | |
| 60 | + | const query = q("q=web&sort=name&page=4"); | |
| 61 | + | assert.equal(projectQueryString(query, { page: 4 }), "?q=web&sort=name&page=4"); | |
| 62 | + | assert.equal(projectQueryString(query, { kind: "library" }), "?q=web&kind=library&sort=name"); | |
| 63 | + | assert.deepEqual(q(projectQueryString(query, { page: 4 }).slice(1)), query); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("archived projects are left out unless asked for", () => { | |
| 67 | + | const list = [project("live"), project("old", { archived: true })]; | |
| 68 | + | assert.deepEqual(filterProjects(list, DEFAULT_QUERY).map((p) => p.slug), ["live"]); | |
| 69 | + | assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, archived: "only" }).map((p) => p.slug), ["old"]); | |
| 70 | + | assert.equal(filterProjects(list, { ...DEFAULT_QUERY, archived: "include" }).length, 2); | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | test("filters narrow by visibility, kind, language and deployments", () => { | |
| 74 | + | const list = [ | |
| 75 | + | project("site", { deploying: true, ecosystem: "npm" }), | |
| 76 | + | project("lib", { kind: "library", ecosystem: "cargo", private: true }), | |
| 77 | + | project("tool", { ecosystem: "go" }), | |
| 78 | + | ]; | |
| 79 | + | const slugs = (change: object) => filterProjects(list, { ...DEFAULT_QUERY, ...change }).map((p) => p.slug).sort(); | |
| 80 | + | assert.deepEqual(slugs({ visibility: "private" }), ["lib"]); | |
| 81 | + | assert.deepEqual(slugs({ visibility: "public" }), ["site", "tool"]); | |
| 82 | + | assert.deepEqual(slugs({ kind: "library" }), ["lib"]); | |
| 83 | + | assert.deepEqual(slugs({ language: "Rust" }), ["lib"]); | |
| 84 | + | assert.deepEqual(slugs({ language: "JavaScript" }), ["site"]); | |
| 85 | + | assert.deepEqual(slugs({ deployments: true }), ["site"]); | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | test("search matches every word, in the name, slug or description, names that start with it first", () => { | |
| 89 | + | const list = [ | |
| 90 | + | project("docs-site", { name: "Docs site", description: "The API reference" }), | |
| 91 | + | project("api", { name: "API" }), | |
| 92 | + | project("billing", { description: "charges cards" }), | |
| 93 | + | ]; | |
| 94 | + | assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, q: "api" }).map((p) => p.slug), ["api", "docs-site"]); | |
| 95 | + | assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, q: "api reference" }).map((p) => p.slug), ["docs-site"]); | |
| 96 | + | assert.deepEqual(filterProjects(list, { ...DEFAULT_QUERY, q: "CARDS" }).map((p) => p.slug), ["billing"]); | |
| 97 | + | }); | |
| 98 | + | ||
| 99 | + | test("each sort orders as it says, ties by name", () => { | |
| 100 | + | const list = [ | |
| 101 | + | project("b", { updatedAt: "2026-10-01T00:00:00Z", pushedAt: "2026-10-05T00:00:00Z", activity: 1 }), | |
| 102 | + | project("a", { updatedAt: "2026-10-03T00:00:00Z", activity: 9 }), | |
| 103 | + | project("c", { updatedAt: "2026-10-02T00:00:00Z", pushedAt: "2026-10-04T00:00:00Z", activity: 1 }), | |
| 104 | + | ]; | |
| 105 | + | const order = (sort: string) => filterProjects(list, { ...DEFAULT_QUERY, sort: sort as never }).map((p) => p.slug); | |
| 106 | + | assert.deepEqual(order("name"), ["a", "b", "c"]); | |
| 107 | + | // Updated is the later of its own changes and its last push. | |
| 108 | + | assert.deepEqual(order("updated"), ["b", "c", "a"]); | |
| 109 | + | assert.deepEqual(order("pushed"), ["b", "c", "a"]); | |
| 110 | + | assert.deepEqual(order("active"), ["a", "b", "c"]); | |
| 111 | + | }); | |
| 112 | + | ||
| 113 | + | test("pages hold PAGE_SIZE, and a page past the end is the last", () => { | |
| 114 | + | const list = Array.from({ length: 412 }, (_, i) => i); | |
| 115 | + | const first = pageOf(list, 1); | |
| 116 | + | assert.equal(first.items.length, PAGE_SIZE); | |
| 117 | + | assert.equal(first.pages, 14); | |
| 118 | + | assert.deepEqual([first.from, first.to], [1, 30]); | |
| 119 | + | const last = pageOf(list, 99); | |
| 120 | + | assert.equal(last.page, 14); | |
| 121 | + | assert.deepEqual([last.from, last.to], [391, 412]); | |
| 122 | + | assert.deepEqual(pageOf([], 1), { items: [], page: 1, pages: 1, from: 0, to: 0 }); | |
| 123 | + | }); | |
| 124 | + | ||
| 125 | + | test("facets count the whole workspace", () => { | |
| 126 | + | const facets = facetsOf([ | |
| 127 | + | project("a", { ecosystem: "npm", deploying: true }), | |
| 128 | + | project("b", { ecosystem: "npm", private: true, kind: "library" }), | |
| 129 | + | project("c", { ecosystem: "go", archived: true }), | |
| 130 | + | ]); | |
| 131 | + | assert.deepEqual(facets.visibility, { public: 2, private: 1 }); | |
| 132 | + | assert.deepEqual(facets.kind, { app: 2, library: 1 }); | |
| 133 | + | assert.deepEqual(facets.languages, [{ name: "JavaScript", count: 2 }, { name: "Go", count: 1 }]); | |
| 134 | + | assert.equal(facets.deploying, 1); | |
| 135 | + | assert.equal(facets.archived, 1); | |
| 136 | + | }); | |
| 137 | + | ||
| 138 | + | test("the keyboard moves through rows and stops at the ends", () => { | |
| 139 | + | assert.equal(nextRow("ArrowDown", -1, 3), 0); | |
| 140 | + | assert.equal(nextRow("j", 0, 3), 1); | |
| 141 | + | assert.equal(nextRow("ArrowDown", 2, 3), 2); | |
| 142 | + | assert.equal(nextRow("k", 0, 3), 0); | |
| 143 | + | assert.equal(nextRow("End", 0, 3), 2); | |
| 144 | + | assert.equal(nextRow("Home", 2, 3), 0); | |
| 145 | + | assert.equal(nextRow("x", 0, 3), null); | |
| 146 | + | assert.equal(nextRow("ArrowDown", 0, 0), null); | |
| 147 | + | }); |
| 1 | + | /** | |
| 2 | + | * A workspace's Projects page, for a workspace with hundreds of them: what | |
| 3 | + | * the address asks for (a search, filters, a sort, a view and a page), and | |
| 4 | + | * the list that answers it. Read from and written to the address, so a | |
| 5 | + | * filtered list can be shared, bookmarked and gone back to. | |
| 6 | + | */ | |
| 7 | + | ||
| 8 | + | import type { Project, ProjectEcosystem } from "@g1t/contracts"; | |
| 9 | + | ||
| 10 | + | export type ProjectSort = "updated" | "name" | "active" | "pushed"; | |
| 11 | + | export type ProjectView = "list" | "grid"; | |
| 12 | + | ||
| 13 | + | export type ProjectQuery = { | |
| 14 | + | /** Words in the name, slug or description. */ | |
| 15 | + | q: string; | |
| 16 | + | visibility: "all" | "public" | "private"; | |
| 17 | + | kind: "all" | "app" | "library"; | |
| 18 | + | /** A language, from the manifests at the project's root. */ | |
| 19 | + | language: string | null; | |
| 20 | + | /** Only projects with Deployments on. */ | |
| 21 | + | deployments: boolean; | |
| 22 | + | /** Archived projects: left out (the default), shown too, or only them. */ | |
| 23 | + | archived: "hide" | "include" | "only"; | |
| 24 | + | sort: ProjectSort; | |
| 25 | + | view: ProjectView; | |
| 26 | + | /** 1 first. */ | |
| 27 | + | page: number; | |
| 28 | + | }; | |
| 29 | + | ||
| 30 | + | /** Projects to a page. */ | |
| 31 | + | export const PAGE_SIZE = 30; | |
| 32 | + | ||
| 33 | + | export const DEFAULT_QUERY: ProjectQuery = { | |
| 34 | + | q: "", | |
| 35 | + | visibility: "all", | |
| 36 | + | kind: "all", | |
| 37 | + | language: null, | |
| 38 | + | deployments: false, | |
| 39 | + | archived: "hide", | |
| 40 | + | sort: "updated", | |
| 41 | + | view: "list", | |
| 42 | + | page: 1, | |
| 43 | + | }; | |
| 44 | + | ||
| 45 | + | export const SORTS: { value: ProjectSort; label: string }[] = [ | |
| 46 | + | { value: "updated", label: "Recently updated" }, | |
| 47 | + | { value: "pushed", label: "Recently pushed" }, | |
| 48 | + | { value: "active", label: "Most active" }, | |
| 49 | + | { value: "name", label: "Name" }, | |
| 50 | + | ]; | |
| 51 | + | ||
| 52 | + | /** The language a project's manifests say it is written in. */ | |
| 53 | + | export const LANGUAGE_OF: Record<ProjectEcosystem, string> = { | |
| 54 | + | npm: "JavaScript", | |
| 55 | + | cargo: "Rust", | |
| 56 | + | go: "Go", | |
| 57 | + | composer: "PHP", | |
| 58 | + | python: "Python", | |
| 59 | + | }; | |
| 60 | + | ||
| 61 | + | /** What the list needs of a project, with whether its Deployments are on. */ | |
| 62 | + | export type Listed = Pick< | |
| 63 | + | Project, | |
| 64 | + | "id" | "slug" | "name" | "description" | "private" | "archived" | "kind" | "ecosystem" | "updatedAt" | "pushedAt" | "activity" | |
| 65 | + | > & { deploying: boolean }; | |
| 66 | + | ||
| 67 | + | const one = <T extends string>(value: string | null, allowed: readonly T[], fallback: T): T => | |
| 68 | + | (allowed as readonly string[]).includes(value ?? "") ? (value as T) : fallback; | |
| 69 | + | ||
| 70 | + | /** What the address asks for; anything it does not say, or says wrong, is the default. */ | |
| 71 | + | export function readProjectQuery(params: URLSearchParams): ProjectQuery { | |
| 72 | + | const page = Number.parseInt(params.get("page") ?? "", 10); | |
| 73 | + | return { | |
| 74 | + | q: (params.get("q") ?? "").trim().slice(0, 100), | |
| 75 | + | visibility: one(params.get("visibility"), ["all", "public", "private"] as const, "all"), | |
| 76 | + | kind: one(params.get("kind"), ["all", "app", "library"] as const, "all"), | |
| 77 | + | language: params.get("language")?.trim() || null, | |
| 78 | + | deployments: params.get("deployments") === "on", | |
| 79 | + | archived: one(params.get("archived"), ["hide", "include", "only"] as const, "hide"), | |
| 80 | + | sort: one(params.get("sort"), ["updated", "name", "active", "pushed"] as const, "updated"), | |
| 81 | + | view: one(params.get("view"), ["list", "grid"] as const, "list"), | |
| 82 | + | page: Number.isFinite(page) && page > 0 ? page : 1, | |
| 83 | + | }; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /** | |
| 87 | + | * The address for `query` with `change` made, leaving out what is the | |
| 88 | + | * default. Changing anything but the page goes back to the first page. | |
| 89 | + | */ | |
| 90 | + | export function projectQueryString(query: ProjectQuery, change: Partial<ProjectQuery> = {}): string { | |
| 91 | + | const next = { ...query, ...change }; | |
| 92 | + | if (!("page" in change)) next.page = 1; | |
| 93 | + | const params = new URLSearchParams(); | |
| 94 | + | if (next.q) params.set("q", next.q); | |
| 95 | + | if (next.visibility !== "all") params.set("visibility", next.visibility); | |
| 96 | + | if (next.kind !== "all") params.set("kind", next.kind); | |
| 97 | + | if (next.language) params.set("language", next.language); | |
| 98 | + | if (next.deployments) params.set("deployments", "on"); | |
| 99 | + | if (next.archived !== "hide") params.set("archived", next.archived); | |
| 100 | + | if (next.sort !== "updated") params.set("sort", next.sort); | |
| 101 | + | if (next.view !== "list") params.set("view", next.view); | |
| 102 | + | if (next.page > 1) params.set("page", String(next.page)); | |
| 103 | + | const text = params.toString(); | |
| 104 | + | return text ? `?${text}` : ""; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /** Whether anything narrows the list, beyond leaving archived projects out. */ | |
| 108 | + | export function isFiltered(query: ProjectQuery): boolean { | |
| 109 | + | return Boolean( | |
| 110 | + | query.q || query.visibility !== "all" || query.kind !== "all" || query.language || query.deployments || query.archived !== "hide", | |
| 111 | + | ); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /** When it last changed: its own settings, or a push, whichever is later. */ | |
| 115 | + | export function lastUpdated(project: Pick<Listed, "updatedAt" | "pushedAt">): string { | |
| 116 | + | return project.pushedAt && project.pushedAt > project.updatedAt ? project.pushedAt : project.updatedAt; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | function matches(project: Listed, words: string[]): boolean { | |
| 120 | + | const text = `${project.name} ${project.slug} ${project.description ?? ""}`.toLowerCase(); | |
| 121 | + | return words.every((word) => text.includes(word)); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /** The projects `query` asks for, in its order, before paging. */ | |
| 125 | + | export function filterProjects(projects: Listed[], query: ProjectQuery): Listed[] { | |
| 126 | + | const words = query.q.toLowerCase().split(/\s+/).filter(Boolean); | |
| 127 | + | const shown = projects.filter( | |
| 128 | + | (project) => | |
| 129 | + | (query.archived === "include" || (query.archived === "only") === project.archived) && | |
| 130 | + | (query.visibility === "all" || (query.visibility === "private") === project.private) && | |
| 131 | + | (query.kind === "all" || project.kind === query.kind) && | |
| 132 | + | (!query.language || (project.ecosystem != null && LANGUAGE_OF[project.ecosystem] === query.language)) && | |
| 133 | + | (!query.deployments || project.deploying) && | |
| 134 | + | (words.length === 0 || matches(project, words)), | |
| 135 | + | ); | |
| 136 | + | return sortProjects(shown, query.sort, words); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | const byName = (a: Listed, b: Listed) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || a.slug.localeCompare(b.slug); | |
| 140 | + | ||
| 141 | + | /** | |
| 142 | + | * In `sort`'s order, ties by name. While searching, a name that starts | |
| 143 | + | * with what was typed comes first. | |
| 144 | + | */ | |
| 145 | + | export function sortProjects(projects: Listed[], sort: ProjectSort, words: string[] = []): Listed[] { | |
| 146 | + | const first = words[0]; | |
| 147 | + | const leads = (project: Listed) => | |
| 148 | + | first != null && (project.name.toLowerCase().startsWith(first) || project.slug.startsWith(first)) ? 0 : 1; | |
| 149 | + | const order: Record<ProjectSort, (a: Listed, b: Listed) => number> = { | |
| 150 | + | name: byName, | |
| 151 | + | updated: (a, b) => lastUpdated(b).localeCompare(lastUpdated(a)), | |
| 152 | + | pushed: (a, b) => (b.pushedAt ?? "").localeCompare(a.pushedAt ?? ""), | |
| 153 | + | active: (a, b) => b.activity - a.activity, | |
| 154 | + | }; | |
| 155 | + | return [...projects].sort((a, b) => leads(a) - leads(b) || order[sort](a, b) || byName(a, b)); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** One page of them, and where it sits among the rest. */ | |
| 159 | + | export function pageOf<T>(list: T[], page: number, size = PAGE_SIZE): { items: T[]; page: number; pages: number; from: number; to: number } { | |
| 160 | + | const pages = Math.max(1, Math.ceil(list.length / size)); | |
| 161 | + | const current = Math.min(Math.max(1, page), pages); | |
| 162 | + | const start = (current - 1) * size; | |
| 163 | + | const items = list.slice(start, start + size); | |
| 164 | + | return { items, page: current, pages, from: items.length ? start + 1 : 0, to: start + items.length }; | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | export type Facets = { | |
| 168 | + | visibility: { public: number; private: number }; | |
| 169 | + | kind: { app: number; library: number }; | |
| 170 | + | languages: { name: string; count: number }[]; | |
| 171 | + | deploying: number; | |
| 172 | + | archived: number; | |
| 173 | + | }; | |
| 174 | + | ||
| 175 | + | /** How many of the workspace's projects each filter would show, before any is chosen. */ | |
| 176 | + | export function facetsOf(projects: Listed[]): Facets { | |
| 177 | + | const languages = new Map<string, number>(); | |
| 178 | + | const facets: Facets = { visibility: { public: 0, private: 0 }, kind: { app: 0, library: 0 }, languages: [], deploying: 0, archived: 0 }; | |
| 179 | + | for (const project of projects) { | |
| 180 | + | facets.visibility[project.private ? "private" : "public"]++; | |
| 181 | + | facets.kind[project.kind]++; | |
| 182 | + | if (project.deploying) facets.deploying++; | |
| 183 | + | if (project.archived) facets.archived++; | |
| 184 | + | if (project.ecosystem) { | |
| 185 | + | const language = LANGUAGE_OF[project.ecosystem]; | |
| 186 | + | languages.set(language, (languages.get(language) ?? 0) + 1); | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | facets.languages = [...languages].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); | |
| 190 | + | return facets; | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /** | |
| 194 | + | * The index of the row to move to from `at` by key, in a list of `count` | |
| 195 | + | * rows: arrows and j/k move one, Home and End to either end. Null for any | |
| 196 | + | * other key. | |
| 197 | + | */ | |
| 198 | + | export function nextRow(key: string, at: number, count: number): number | null { | |
| 199 | + | if (count === 0) return null; | |
| 200 | + | switch (key) { | |
| 201 | + | case "ArrowDown": | |
| 202 | + | case "j": | |
| 203 | + | return Math.min(count - 1, at + 1); | |
| 204 | + | case "ArrowUp": | |
| 205 | + | case "k": | |
| 206 | + | return Math.max(0, at - 1); | |
| 207 | + | case "Home": | |
| 208 | + | return 0; | |
| 209 | + | case "End": | |
| 210 | + | return count - 1; | |
| 211 | + | default: | |
| 212 | + | return null; | |
| 213 | + | } | |
| 214 | + | } |
| 66 | 66 | today: { label: "Outcomes", path: "plans" }, | |
| 67 | 67 | }, | |
| 68 | 68 | { | |
| 69 | + | key: "insights", | |
| 70 | + | title: "Insights", | |
| 71 | + | section: "Workspace", | |
| 72 | + | summary: "How the whole workspace delivers: lead time, reviews, what agents do and what it costs, across every project.", | |
| 73 | + | why: "Each project will have its own numbers. The workspace's put them side by side, so you can see which projects ship steadily, where work waits, and where the money goes.", | |
| 74 | + | plans: [ | |
| 75 | + | "Delivery metrics for every project, side by side", | |
| 76 | + | "Where work waits: review, the merge queue, checks", | |
| 77 | + | "Agents' share of merged changes, and how often it lands first time", | |
| 78 | + | "Cost by project, by kind of work and per merged change", | |
| 79 | + | ], | |
| 80 | + | }, | |
| 81 | + | { | |
| 69 | 82 | key: "milestones", | |
| 70 | 83 | title: "Milestones", | |
| 71 | 84 | section: "Issues", |
| 1 | + | import type { Project, Result, Viewer } from "@g1t/contracts"; | |
| 2 | + | ||
| 3 | + | import { shortCache } from "./cache.server"; | |
| 4 | + | import { projects } from "./services.server"; | |
| 5 | + | ||
| 6 | + | /** How long one isolate keeps a person's list of a workspace's projects. */ | |
| 7 | + | export const PROJECTS_TTL_MS = 15_000; | |
| 8 | + | ||
| 9 | + | /** | |
| 10 | + | * A workspace's projects as the viewer may see them: one listing, kept for | |
| 11 | + | * a few seconds per person and workspace (lib/cache.server.ts), so the | |
| 12 | + | * sidebar, the workspace's tabs and its Projects page share it. | |
| 13 | + | */ | |
| 14 | + | export function workspaceProjects(slug: string, viewer: Viewer): Promise<Result<Project[]>> { | |
| 15 | + | const workspace = slug.toLowerCase(); | |
| 16 | + | return shortCache(`shell:projects:${viewer?.id ?? "visitor"}:${workspace}`, PROJECTS_TTL_MS, () => projects.list(workspace, viewer)); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /** What a project is in the sidebar and the command palette. */ | |
| 20 | + | export function shortcutOf(project: Project): { namespace: string; name: string; title: string; isPrivate: boolean } { | |
| 21 | + | return { namespace: project.workspace, name: project.slug, title: project.name, isPrivate: project.private }; | |
| 22 | + | } |
| 46 | 46 | DropdownMenuSeparator, | |
| 47 | 47 | DropdownMenuTrigger, | |
| 48 | 48 | } from "./components/ui/dropdown-menu"; | |
| 49 | − | import { AppShell, Progress, type ShellData } from "./components/shell"; | |
| 49 | + | import { AppShell, Progress, type ShellData, useLeaving } from "./components/shell"; | |
| 50 | 50 | import { SiteFooter } from "./components/footer"; | |
| 51 | 51 | import { SpikeBanner } from "./components/spike-banner"; | |
| 52 | 52 | import { readCookie } from "./lib/mission"; | |
| 58 | 58 | import { countsFor, readableRepos } from "./lib/access.server"; | |
| 59 | 59 | import { shortCache } from "./lib/cache.server"; | |
| 60 | 60 | import { getViewer, viewerMiddleware } from "./lib/session.server"; | |
| 61 | + | import { shortcutOf, workspaceProjects } from "./lib/workspace-projects.server"; | |
| 61 | 62 | import { registrationMode } from "./lib/registration.server"; | |
| 62 | 63 | import { addresses } from "./lib/addresses.server"; | |
| 63 | 64 | import { useSignUpCopy } from "./lib/registration"; | |
| 140 | 141 | // something (lib/cache.server.ts). | |
| 141 | 142 | const kept = <T,>(what: string, load: () => Promise<T>) => | |
| 142 | 143 | workspace ? shortCache(`shell:${what}:${user.id}:${workspace.slug}`, SHELL_TTL_MS, load) : Promise.resolve(null); | |
| 143 | − | const [listed, counts, status, usage, limit, entitlements, shared, unread] = await Promise.all([ | |
| 144 | − | kept("projects", () => projects.list(workspace!.slug, user)), | |
| 144 | + | const [listed, counts, status, usage, limit, entitlements, shared, unread, shortcuts] = await Promise.all([ | |
| 145 | + | // Every project, for the palette and the count; the sidebar lists only | |
| 146 | + | // the person's pinned and recent ones (lib/pins.ts). | |
| 147 | + | workspace ? workspaceProjects(workspace.slug, user) : Promise.resolve(null), | |
| 145 | 148 | path ? countsFor(context, params) : Promise.resolve(null), | |
| 146 | 149 | // Whether billing is on, without reading the account: that asks the | |
| 147 | 150 | // card processor about the workspace's cards, too slow for every page. | |
| 152 | 155 | sharedRepos(user), | |
| 153 | 156 | // The bell's count: one query, never kept, so marking an item shows at once. | |
| 154 | 157 | inbox.counts(user.username).catch(() => null), | |
| 158 | + | // Never kept: opening a project moves it up Recent. | |
| 159 | + | workspace ? projects.shortcuts(workspace.slug, user).catch(() => null) : Promise.resolve(null), | |
| 155 | 160 | ]); | |
| 156 | 161 | return { | |
| 157 | 162 | workspace, | |
| 158 | 163 | // Projects are what the sidebar lists: what the workspace builds and runs. | |
| 159 | − | repos: listed?.ok | |
| 160 | − | ? listed.value.map((project) => ({ | |
| 161 | − | namespace: project.workspace, | |
| 162 | − | name: project.slug, | |
| 163 | − | title: project.name, | |
| 164 | − | isPrivate: project.private, | |
| 165 | − | })) | |
| 166 | − | : [], | |
| 164 | + | repos: listed?.ok ? listed.value.map(shortcutOf) : [], | |
| 165 | + | pinned: (shortcuts?.pinned ?? []).map(shortcutOf), | |
| 166 | + | recent: (shortcuts?.recent ?? []).map(shortcutOf), | |
| 167 | 167 | repo: | |
| 168 | 168 | path && counts?.ok | |
| 169 | 169 | ? { | |
| 469 | 469 | </SubmitButton> | |
| 470 | 470 | </Form> | |
| 471 | 471 | ); | |
| 472 | + | const leaving = useLeaving(); | |
| 472 | 473 | const banner = | |
| 473 | 474 | paused || verify ? ( | |
| 474 | 475 | <> | |
| 498 | 499 | <Progress /> | |
| 499 | 500 | <Header user={user} /> | |
| 500 | 501 | {banner} | |
| 501 | − | <div className="grow">{children}</div> | |
| 502 | + | <div {...leaving} className={`grow ${leaving.className}`}> | |
| 503 | + | {children} | |
| 504 | + | </div> | |
| 502 | 505 | <SiteFooter user={user} /> | |
| 503 | 506 | </> | |
| 504 | 507 | )} |
| 62 | 62 | // A workspace's own pages sit under `-`, which no repository can be named. | |
| 63 | 63 | route(":owner", "routes/workspace/layout.tsx", [ | |
| 64 | 64 | index("routes/workspace/overview.tsx"), | |
| 65 | + | // Its tabs: Overview (above), Projects, Packages, Teams, People, Insights. | |
| 66 | + | route("-/projects", "routes/workspace/projects.tsx"), | |
| 65 | 67 | route("-/people", "routes/workspace/people.tsx"), | |
| 68 | + | route("-/teams", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/teams" }), | |
| 69 | + | route("-/insights", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/insights" }), | |
| 70 | + | // Pinning its projects, for the person signed in. | |
| 71 | + | route("-/pins", "routes/workspace/pins.ts"), | |
| 72 | + | // Pages that moved: Members is People, and the overview is the workspace. | |
| 73 | + | route("-/members", "routes/workspace/moved.ts", { id: "routes/workspace/moved-members" }), | |
| 74 | + | route("-/overview", "routes/workspace/moved.ts", { id: "routes/workspace/moved-overview" }), | |
| 66 | 75 | route("-/tokens", "routes/workspace/tokens.tsx"), | |
| 67 | 76 | route("-/usage", "routes/workspace/usage.tsx"), | |
| 68 | 77 | route("-/billing", "routes/workspace/billing.tsx"), |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.