Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

7 commits

58 files+1805−1210/58 viewed
+1−0
1212 mod notifications;
1313 mod oauth;
1414 mod openapi;
15+mod pins;
1516 mod operations;
1617 mod renamed;
1718 #[cfg(test)]
+9−0
3939 ],
4040 ),
4141 (
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+ (
4247 "Workspaces",
4348 "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.",
4449 &[Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace],
373378 Op::SetRepoSubscription => "Watch a repository",
374379 Op::DeleteRepoSubscription => "Stop watching a repository",
375380 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",
376385 }
377386 }
378387
+70−3
4444 pub search: Fetcher,
4545 /// Secret and dependency alerts.
4646 pub security: Fetcher,
47+ /// Projects: a person's pinned ones.
48+ pub projects: Fetcher,
4749 /// Where the request came in, for its audit entries.
4850 pub audit: crate::audit::AuditContext,
4951 /// Set for a request made with an agent's token: all it may do.
6769 context: env.service("CONTEXT")?,
6870 search: env.service("SEARCH")?,
6971 security: env.service("SECURITY")?,
72+ projects: env.service("PROJECTS")?,
7073 scope: None,
7174 audit: crate::audit::AuditContext::default(),
7275 addresses: crate::addresses::Addresses::from_env(env),
207210 SetRepoSubscription,
208211 DeleteRepoSubscription,
209212 ListWatchedRepos,
213+ ListPinnedProjects,
214+ PinProject,
215+ UnpinProject,
216+ ReorderPinnedProjects,
210217 }
211218
212219 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
454461 }
455462
456463 impl Op {
457− pub const ALL: [Op; 131] = [
464+ pub const ALL: [Op; 135] = [
458465 Op::Whoami,
459466 Op::CreateWorkspace,
460467 Op::DeleteWorkspace,
586593 Op::SetRepoSubscription,
587594 Op::DeleteRepoSubscription,
588595 Op::ListWatchedRepos,
596+ Op::ListPinnedProjects,
597+ Op::PinProject,
598+ Op::UnpinProject,
599+ Op::ReorderPinnedProjects,
589600 ];
590601
591602 pub fn by_name(name: &str) -> Option<Op> {
726737 Op::SetRepoSubscription => "set_repo_subscription",
727738 Op::DeleteRepoSubscription => "delete_repo_subscription",
728739 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",
729744 }
730745 }
731746
10901105 Op::ListWatchedRepos => {
10911106 "The repositories you watch other than the default way: all activity, custom or ignored, each with its `level` and `events`."
10921107 }
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+ }
10931120 }
10941121 }
10951122
20802107 &["repo"],
20812108 ),
20822109 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+ ),
20832137 }
20842138 }
20852139
21772231 | Op::SetThreadSubscription
21782232 | Op::DeleteThreadSubscription
21792233 | Op::ListWatchedRepos
2234+ | Op::ListPinnedProjects
2235+ | Op::PinProject
2236+ | Op::UnpinProject
2237+ | Op::ReorderPinnedProjects
21802238 )
21812239 }
21822240
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.
21852244 pub(crate) fn personal(self) -> bool {
21862245 matches!(
21872246 self,
21992258 | Op::SetRepoSubscription
22002259 | Op::DeleteRepoSubscription
22012260 | Op::ListWatchedRepos
2261+ | Op::ListPinnedProjects
2262+ | Op::PinProject
2263+ | Op::UnpinProject
2264+ | Op::ReorderPinnedProjects
22022265 )
22032266 }
22042267
36173680 | Op::SetRepoSubscription
36183681 | Op::DeleteRepoSubscription
36193682 | 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+ }
36203687 Op::ReopenSecurityAlert => {
36213688 let changed: Outcome<AlertChange> = call(
36223689 &services.security,
+153−0
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+}
+204−1
4343 ],
4444 "token": {
4545 "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+ ]
4753 }
4854 },
4955 "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/)."
49774983 "updated_at": "2026-10-05T14:30:00.000Z"
49784984 }
49794985 ]
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`."
49805183 }
49815184 }
+5−0
7979 route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]),
8080 route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]),
8181 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, &[]),
8287 route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
8388 route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
8489 route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]),
+5−1
227227 Tool {
228228 name: "workspace",
229229 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.",
231231 default_action: None,
232232 actions: &[
233233 a("create", Op::CreateWorkspace, "Create a workspace"),
242242 a("test_integration", Op::TestIntegration, "Check its credentials"),
243243 a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"),
244244 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"),
245249 ],
246250 },
247251 Tool {
+3−1
2929 { "binding": "CONTEXT", "service": "g1t-context" },
3030 { "binding": "SEARCH", "service": "g1t-search" },
3131 // 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" }
3335 ],
3436 // GitHub Actions artifacts, in chunks, with KV's own expiry (and cache
3537 // entries saved before the cache moved to R2, until they expire).
+2−2
105105
106106 Owners always have Admin, whatever it says. Only owners can change it:
107107
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.
109109 2. Under **Base permission**, choose one.
110110
111111 It takes effect on everyone's next request. To give one member more on
179179
180180 Owners see every outside collaborator, and the repositories and roles each
181181 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
183183 (see [members and roles](/guides/workspaces/#members-and-roles)); the
184184 roles they have stay, and the base permission adds to them.
185185
+2−2
351351 | `workflows:write` | Run, cancel, rerun and turn workflows on or off |
352352 | `memory:read` | Recall memory and search the workspace's context |
353353 | `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 |
356356 | `notifications:read` | See your [inbox](/guides/inbox/), its threads, and what you subscribe to and watch |
357357 | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories |
358358 | `workspace:read` | Read workspace invites, integrations and model routes |
+6−3
2424 repository. Repositories made before projects existed became projects the
2525 first time their workspace was opened.
2626
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).
3033
3134 ## A project's pages
3235
+82−15
270270 | 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/). |
271271
272272 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):
274275
275276 - **By username**: someone already on g1t joins at once, as a member.
276277 - **By email address**: g1t emails an invite that only that address can
294295 To give someone a role on one repository without making them a member,
295296 add them as an [outside collaborator](/guides/access-and-roles/#outside-collaborators).
296297
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:
298302
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)
303315 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:
306325
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
313359 **Settings**. An item with an arrow opens a list of its own in the sidebar:
314360 **Settings** slides over to how the workspace is set up and connected, and
315361 the row at the top, **‹ Settings**, slides back:
327373 | **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. |
328374 | **Audit log** | Members | [Every action agents, people and tokens took](/guides/audit-log/). |
329375
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
331377 remove people there, set the
332378 [base permission](/guides/access-and-roles/#the-base-permission), and see
333379 the **Outside collaborators** tab.
340386 for the roles that can use it. A link straight to any of these pages opens
341387 the sidebar already there.
342388
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+
343410 ## Mission control
344411
345412 Mission control, `g1t.sh` when you are signed in, is your home page. It
+8−3
432432
433433 ## `workspace`
434434
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/).
438439
439440 | Action | What it does | Required | Scope |
440441 | --- | --- | --- | --- |
450451 | [`test_integration`](/reference/api/integrations/test-integration/) | Check its credentials against the system it connects to. Owners only. | `workspace`, `id` | `workspace:admin` |
451452 | [`get_model_routes`](/reference/api/integrations/get-model-routes/) | Which provider and model each kind of work goes to. Members only. | `workspace` | `workspace:read` |
452453 | [`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` |
453458
454459 ## `notifications`
455460
+0−0

Binary or large file; its contents are not shown.

+45−13
304304 };
305305 }
306306
307−type Live = { runs: AgentRun[]; member: boolean };
307+export type Live = { runs: AgentRun[]; member: boolean };
308308
309309 /**
310310 * 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.
312314 */
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 {
314321 const fetcher = useFetcher<Live>();
315322 const search = new URLSearchParams(query).toString();
316323 const url = `/${owner}/${repo}/agents.json?${search}`;
317324 const { load } = fetcher;
325+ const loaded = initial !== undefined;
318326 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;
322340 useEffect(() => {
323341 if (!live) return;
324342 const timer = setInterval(() => {
326344 }, LIVE_MS);
327345 return () => clearInterval(timer);
328346 }, [live, load, url]);
329− return fetcher.data ?? null;
347+ return data;
330348 }
331349
332350 /** "Agent confidence: Low — tests not added, 3 revisions", and what the agent said it was unsure of. */
361379 number,
362380 stage,
363381 confidence,
382+ runs: initial,
364383 }: {
365384 owner: string;
366385 repo: string;
368387 stage?: Stage | null;
369388 /** How sure g1t is of the change, once the agent has finished it. */
370389 confidence?: Confidence | null;
390+ /** Its latest five runs, from the page's loader, so the panel comes with the page. */
391+ runs?: Live | null;
371392 }) {
372− const data = useRuns(owner, repo, { number: String(number), limit: "5" });
393+ const data = useRuns(owner, repo, { number: String(number), limit: "5" }, initial);
373394 const runs = data?.runs ?? [];
374395 const member = data?.member ?? false;
375396 const current = runs.find((run) => isActiveRun(run.status)) ?? runs[0];
464485 }
465486
466487 /** 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);
469490 return useMemo(() => {
470491 const byNumber = new Map<number, AgentRun>();
471492 for (const run of data?.runs ?? []) {
490511 * What the agent on a pull request is doing this minute, as one line, for
491512 * an issue's sidebar: which agent picked the issue up, and where it is.
492513 */
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));
496528 if (!run?.step) return null;
497529 return (
498530 <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={shownStep(run.step)}>
+10−2
1111
1212 import { actionLabel, actorLabel, ruleLabel, targetLabel } from "../lib/audit";
1313 import { Avatar, TimeAgo } from "./ui";
14+import { Loading, SkeletonRows } from "./ui/skeleton";
1415
1516 function clock(at: string): string {
1617 return new Date(at).toISOString().slice(11, 19);
168169 return () => clearInterval(timer);
169170 }, [live, load, url]);
170171 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.
172174 return (
173175 <details className="mt-3 group">
174176 <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+ )}
176184 <Link to={`/${owner}/-/audit?project=${encodeURIComponent(repo)}&kind=agent`} className="mt-2 inline-block text-xs text-muted hover:text-fg">
177185 Open the audit log
178186 </Link>
+16−4
2727 import { useAddresses } from "../lib/addresses";
2828 import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up";
2929 import { duration } from "./actions";
30−import { Elapsed, useRuns } from "./agents";
30+import { Elapsed, type Live, useRuns } from "./agents";
3131 import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "./ui";
3232 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
33+import { SkeletonLine } from "./ui/skeleton";
3334
3435 // --- Output ---------------------------------------------------------------
3536
210211 name,
211212 detail,
212213 time,
214+ timing = false,
213215 to,
214216 }: {
215217 standing: Standing;
216218 name: React.ReactNode;
217219 detail?: React.ReactNode;
218220 time?: string;
221+ /** Its jobs and their times are still being read. */
222+ timing?: boolean;
219223 to?: string;
220224 }) {
221225 return (
225229 {name}
226230 {detail && <span className="text-muted"> — {detail}</span>}
227231 </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" />}
229234 {to && (
230235 <Link to={to} className="shrink-0 text-xs text-muted hover:text-fg hover:underline">
231236 Details
313318 canRerunWorkflows,
314319 settingsUrl,
315320 error,
321+ loading = false,
316322 }: {
317323 /** Set when the merge queue took it out, or for a run from before checks were workflows. */
318324 run: CheckRun | null;
328334 /** Where the required checks are chosen, for those who may. */
329335 settingsUrl?: string | null;
330336 error?: string | null;
337+ /** The workflow runs' jobs are still being read: their rows wait, busy. */
338+ loading?: boolean;
331339 }) {
332340 if (!run && statuses.length === 0 && required.length === 0) return null;
333341
379387 const failedRuns = statuses.filter((status) => statusStanding(status) === "failed" && runIdOf(status));
380388
381389 return (
382− <div>
390+ <div aria-busy={loading || undefined}>
383391 <div className="flex items-start gap-3 px-4 py-3">
384392 <span className="mt-0.5 shrink-0">
385393 {standing === "failed" || queueFailed ? (
466474 standing={statusStanding(status)}
467475 name={status.context}
468476 detail={status.description ?? undefined}
477+ timing={loading && id != null}
469478 to={runPath}
470479 />
471480 );
775784 update,
776785 startedAt,
777786 retrying,
787+ runs: initial,
778788 }: {
779789 owner: string;
780790 repo: string;
786796 startedAt: number;
787797 /** Whether a retry is on its way. */
788798 retrying: boolean;
799+ /** The pull request's latest runs, from the page's loader. */
800+ runs?: Live | null;
789801 }) {
790802 // 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);
792804 const [now, setNow] = useState(() => Date.now());
793805 useEffect(() => {
794806 const timer = setInterval(() => setNow(Date.now()), 5000);
+57−0
1+/**
2+ * Pinning a project, so it stays at the top of the workspace's sidebar:
3+ * a button on the project's header and on each row of the Projects page.
4+ * Posts to the workspace's pins route (routes/workspace/pins.ts); while it
5+ * is on its way, it shows as done.
6+ */
7+import { Pin } from "lucide-react";
8+import { useFetcher } from "react-router";
9+
10+import { cn } from "../lib/cn";
11+
12+export function PinButton({
13+ workspace,
14+ slug,
15+ name,
16+ pinned,
17+ compact,
18+ className,
19+}: {
20+ workspace: string;
21+ slug: string;
22+ /** For what the button says to a screen reader. */
23+ name: string;
24+ pinned: boolean;
25+ /** Only the pin, as on a row; the header says it in words too. */
26+ compact?: boolean;
27+ className?: string;
28+}) {
29+ const fetcher = useFetcher<{ error: string | null }>({ key: `pin:${workspace}/${slug}` });
30+ const asked = fetcher.formData?.get("intent");
31+ const shown = asked === "pin" ? true : asked === "unpin" ? false : pinned;
32+ const label = shown ? `Unpin ${name}` : `Pin ${name}`;
33+ const error = fetcher.state === "idle" ? fetcher.data?.error : null;
34+ return (
35+ <fetcher.Form method="post" action={`/${workspace}/-/pins`} className={cn("relative flex", className)}>
36+ <input type="hidden" name="intent" value={shown ? "unpin" : "pin"} />
37+ <input type="hidden" name="slug" value={slug} />
38+ <button
39+ type="submit"
40+ aria-label={label}
41+ aria-pressed={shown}
42+ title={error ?? (shown ? "Unpin from the sidebar" : "Pin to the sidebar")}
43+ className={cn(
44+ "inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-[0.8125rem] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent",
45+ compact
46+ ? "relative z-10 size-8 hover:bg-raised"
47+ : "h-8 border border-line px-2.5 text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
48+ shown ? "text-accent" : compact ? "text-faint hover:text-fg" : "",
49+ error && "text-danger",
50+ )}
51+ >
52+ <Pin size={14} className={shown ? "fill-current" : undefined} />
53+ {!compact && <span className="hidden sm:inline">{shown ? "Unpin" : "Pin"}</span>}
54+ </button>
55+ </fetcher.Form>
56+ );
57+}
+6−4
1515 import { Avatar, CopyLine, TimeAgo, notACredential } from "./ui";
1616 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
1717 import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
18+import { SkeletonLine } from "./ui/skeleton";
1819
1920 function encodePath(path: string): string {
2021 return path.split("/").map(encodeURIComponent).join("/");
238239 }) {
239240 const byName = new Map((last?.entries ?? []).map((entry) => [entry.name, entry.commit]));
240241 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">
242244 {entries.map((entry) => {
243245 const isTree = entry.kind === "tree";
244246 const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File;
256258 {commit ? (
257259 <Link
258260 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"
260262 title={commit.message.split("\n")[0]}
261263 >
262264 {commit.message.split("\n")[0]}
263265 </Link>
264266 ) : 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" />
266268 ) : null}
267269 </span>
268270 <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}
270272 </span>
271273 </li>
272274 );
+182−52
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";
22 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33 import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
2727 import { withNext } from "../lib/next";
2828 import { useSignUpCopy } from "../lib/registration";
2929 import { STATUS_URL, statusTitle } from "../lib/status";
30+import { type ShortcutProject, movedPin, recentWith } from "../lib/pins";
3031 import type { AccountMenuData } from "../routes/settings-menu-json";
3132
3233 /**
3738 export type ShellData = {
3839 /** The workspace the sidebar is about: the one being looked at, or their first. */
3940 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[];
4247 /** Repositories shared with them in workspaces they do not belong to. */
4348 shared?: { namespace: string; name: string; isPrivate: boolean }[];
4449 /** The project being looked at, if any, whoever owns it. */
250255 <ChevronsUpDown size={14} />
251256 </DropdownMenuTrigger>
252257 <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+ )}
253276 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
254277 {(user.workspaces ?? []).map((membership) => (
255278 <DropdownMenuItem asChild key={membership.slug}>
550573 return <div role="separator" className="mx-2 my-2.5 h-px bg-line" />;
551574 }
552575
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+
553679 /**
554680 * The main list: where you go, the projects, what the workspace builds and
555681 * runs with across them, then its usage, support and settings.
556682 */
557683 function MainMenu({ user, shell }: { user: User | null; shell: ShellData }) {
558684 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;
565685 // A visitor browses: no workspace, no projects of their own.
566686 if (!user) {
567687 return (
578698 }
579699 return (
580700 <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. */}
581704 <div className="mt-3 space-y-px">
582705 <SidebarLink to="/" end icon={<House size={15} />}>
583706 Mission control
584707 </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>
593708 </div>
594709
595710 <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+ )}
623718 {(shell.shared ?? []).length > 0 && (
624719 <SidebarGroup title="Shared with you" className="mt-3">
625720 {(shell.shared ?? []).map((repo) => (
639734 {ws && (
640735 <>
641736 <Rule />
642− <div className="space-y-px">
737+ <SidebarGroup title="Workspace">
643738 <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}>
644739 Agent fleet
645740 </SidebarLink>
655750 <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />}>
656751 Packages
657752 </SidebarLink>
658− {roadmapIn("Workspace").filter((item) => item.key !== "teams").map((item) => (
753+ {roadmapIn("Workspace").filter((item) => item.key !== "teams" && item.key !== "insights").map((item) => (
659754 <SidebarSoonLink
660755 key={item.key}
661756 to={`/${ws.slug}/-/soon/${item.key}`}
665760 {item.title === "Board" ? "Boards" : item.title}
666761 </SidebarSoonLink>
667762 ))}
668− </div>
763+ </SidebarGroup>
669764
670765 <Rule />
671766 <div className="space-y-px">
672767 {/* Who belongs, for every member to see; owners invite and manage there. */}
673768 <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}>
674− Members
769+ People
675770 </SidebarLink>
676771 {roadmapIn("Workspace")
677772 .filter((item) => item.key === "teams")
678773 .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}>
680775 {item.title}
681776 </SidebarSoonLink>
682777 ))}
11311226 const back = !user
11321227 ? { to: "/explore", label: "Explore" }
11331228 : home
1134− ? { to: `/${home.slug}`, label: "All projects" }
1229+ ? { to: `/${home.slug}/-/projects`, label: "All projects" }
11351230 : { to: "/", label: "Mission control" };
11361231 trail.push({
11371232 key,
12191314 code: "Files",
12201315 secrets: "Secrets and variables",
12211316 settings: "Settings",
1222− people: "Members",
1317+ people: "People",
1318+ projects: "Projects",
1319+ teams: "Teams",
12231320 tokens: "Access tokens",
12241321 usage: "Usage",
12251322 billing: "Billing and plans",
13861483 for (const membership of user.workspaces ?? []) {
13871484 commands.push(
13881485 { 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} /> },
13891488 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
13901489 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
13911490 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
14401539 }
14411540
14421541 /**
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+/**
14431558 * A bar across the top of the page while the next one loads, as GitHub
14441559 * has: it appears at once, creeps towards the end while waiting, then fills
14451560 * and fades when the page arrives.
15201635 else setTimeout(load, 1500);
15211636 }, [user]);
15221637 usePaletteShortcut(() => setPalette((open) => !open));
1638+ const leaving = useLeaving();
15231639
15241640 return (
15251641 <div className="min-h-screen">
15661682 </kbd>
15671683 </Form>
15681684 <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>
15691697 <a
15701698 href="https://docs.g1t.sh/"
15711699 className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
16301758 </div>
16311759 </header>
16321760 {banner}
1633− <main className="min-w-0 grow">{children}</main>
1761+ <main {...leaving} className={`min-w-0 grow ${leaving.className}`}>
1762+ {children}
1763+ </main>
16341764 </div>
16351765 <CommandPalette
16361766 open={palette}
+151−3
1−import type { ComponentProps } from "react";
1+import type { ComponentProps, ReactNode } from "react";
22
33 import { cn } from "../../lib/cn";
44
55 // The shape of something still loading: a quiet raised block that pulses
66 // (still for people who ask for less motion). Give it the size of what it
77 // 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.
913
1014 /** One block. Size it with classes: `h-3 w-24`, `size-8 rounded-full`. */
1115 export function Skeleton({ className, ...props }: ComponentProps<"span">) {
1923 }
2024
2125 /**
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+/**
2258 * Lines of text: `lines` bars `h-3` apart by `gap`, the last one shorter,
2359 * as a paragraph or a list's titles would sit.
2460 */
4278 {Array.from({ length: rows }, (_, index) => (
4379 <div key={index} className={cn("flex h-10 items-center gap-3 px-3", rowClassName)}>
4480 <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) }} />
4682 <Skeleton className="h-3 w-12 shrink-0" />
4783 </div>
4884 ))}
4985 </div>
5086 );
5187 }
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+}
+98−0
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+}
+49−0
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+});
+65−0
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+}
+147−0
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+});
+214−0
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+}
+13−0
6666 today: { label: "Outcomes", path: "plans" },
6767 },
6868 {
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+ {
6982 key: "milestones",
7083 title: "Milestones",
7184 section: "Issues",
+53−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { workspaceRedirect, workspaceTab, workspaceTabs } from "./workspace-nav.ts";
5+
6+test("everyone sees Overview, Projects and Packages; members and owners see more", () => {
7+ const keys = (member: boolean, owner: boolean) => workspaceTabs("acme", { member, owner }).map((tab) => tab.key);
8+ assert.deepEqual(keys(false, false), ["overview", "projects", "packages"]);
9+ assert.deepEqual(keys(true, false), ["overview", "projects", "packages", "teams", "people", "insights"]);
10+ assert.deepEqual(keys(true, true), ["overview", "projects", "packages", "teams", "people", "insights", "settings"]);
11+});
12+
13+test("tabs carry counts, and say which are coming", () => {
14+ const tabs = workspaceTabs("acme", { member: true, owner: false, projects: 412, people: 7 });
15+ assert.equal(tabs.find((tab) => tab.key === "projects")?.count, 412);
16+ assert.equal(tabs.find((tab) => tab.key === "people")?.count, 7);
17+ assert.equal(tabs.find((tab) => tab.key === "teams")?.soon, true);
18+ assert.equal(tabs.find((tab) => tab.key === "overview")?.to, "/acme");
19+ assert.equal(tabs.find((tab) => tab.key === "projects")?.to, "/acme/-/projects");
20+});
21+
22+test("a path is a tab, or one of the workspace's other pages", () => {
23+ assert.equal(workspaceTab("/acme", "acme"), "overview");
24+ assert.equal(workspaceTab("/Acme/", "acme"), "overview");
25+ assert.equal(workspaceTab("/acme/-/projects", "acme"), "projects");
26+ assert.equal(workspaceTab("/acme/-/people", "acme"), "people");
27+ assert.equal(workspaceTab("/acme/-/packages", "acme"), "packages");
28+ // One package has a page of its own; settings and the rest have headings.
29+ assert.equal(workspaceTab("/acme/-/packages/npm/web", "acme"), null);
30+ assert.equal(workspaceTab("/acme/-/settings", "acme"), null);
31+ assert.equal(workspaceTab("/acme/-/agents", "acme"), null);
32+ assert.equal(workspaceTab("/acme/web", "acme"), null);
33+ assert.equal(workspaceTab("/other", "acme"), null);
34+});
35+
36+test("old addresses go to where their pages are now", () => {
37+ assert.equal(workspaceRedirect("/acme/-/members"), "/acme/-/people");
38+ assert.equal(workspaceRedirect("/acme/-/overview"), "/acme");
39+ assert.equal(workspaceRedirect("/acme/-/soon/teams"), "/acme/-/teams");
40+ assert.equal(workspaceRedirect("/acme/-/soon/insights", "?x=1"), "/acme/-/insights?x=1");
41+ assert.equal(workspaceRedirect("/acme/-/soon/board"), null);
42+ assert.equal(workspaceRedirect("/acme/-/people"), null);
43+ assert.equal(workspaceRedirect("/acme/web"), null);
44+});
45+
46+test("?tab= opens that tab, keeping the rest of the query", () => {
47+ assert.equal(workspaceRedirect("/acme", "?tab=repositories"), "/acme/-/projects");
48+ assert.equal(workspaceRedirect("/acme", "?tab=projects&q=api"), "/acme/-/projects?q=api");
49+ assert.equal(workspaceRedirect("/acme", "?tab=members"), "/acme/-/people");
50+ assert.equal(workspaceRedirect("/acme", "?tab=overview"), "/acme");
51+ assert.equal(workspaceRedirect("/acme", "?tab=nonsense"), null);
52+ assert.equal(workspaceRedirect("/acme", ""), null);
53+});
+103−0
1+/**
2+ * A workspace's own page at `g1t.sh/<workspace>`: its header, and tabs for
3+ * what it has (Overview, Projects, Packages, Teams, People, Insights and,
4+ * for owners, Settings). The sidebar is always the current workspace's;
5+ * this page is where the workspace itself is shown, to members and to
6+ * everyone else.
7+ */
8+
9+export type WorkspaceTabKey = "overview" | "projects" | "packages" | "teams" | "people" | "insights" | "settings";
10+
11+export type WorkspaceTab = {
12+ key: WorkspaceTabKey;
13+ label: string;
14+ to: string;
15+ /** Shown beside the label; left out when unknown. */
16+ count?: number | null;
17+ /** Not built yet: its tab opens what it will be. */
18+ soon?: boolean;
19+};
20+
21+/** The workspace's pages under `-/` that are drawn under its tabs. */
22+export const TAB_PAGES = ["projects", "packages", "teams", "people", "insights"] as const;
23+
24+/** Tabs whose pages say what they will be, until they are built. */
25+export const SOON_TABS = new Set<WorkspaceTabKey>(["teams", "insights"]);
26+
27+/**
28+ * The tabs, in order. People, Teams and Insights are for members, and
29+ * Settings for owners; everyone sees the rest.
30+ */
31+export function workspaceTabs(
32+ slug: string,
33+ options: { member: boolean; owner: boolean; projects?: number | null; people?: number | null },
34+): WorkspaceTab[] {
35+ const base = `/${slug}`;
36+ const tabs: (WorkspaceTab | false)[] = [
37+ { key: "overview", label: "Overview", to: base },
38+ { key: "projects", label: "Projects", to: `${base}/-/projects`, count: options.projects ?? null },
39+ { key: "packages", label: "Packages", to: `${base}/-/packages` },
40+ options.member && { key: "teams", label: "Teams", to: `${base}/-/teams`, soon: true },
41+ options.member && { key: "people", label: "People", to: `${base}/-/people`, count: options.people ?? null },
42+ options.member && { key: "insights", label: "Insights", to: `${base}/-/insights`, soon: true },
43+ options.owner && { key: "settings", label: "Settings", to: `${base}/-/settings` },
44+ ];
45+ return tabs.filter((tab): tab is WorkspaceTab => tab !== false);
46+}
47+
48+/**
49+ * Which tab a path is, under `/<workspace>`: null for the workspace's other
50+ * pages (its settings, Agent fleet, Usage and the rest), which have a page
51+ * heading of their own, and for one package's page.
52+ */
53+export function workspaceTab(pathname: string, slug: string): WorkspaceTabKey | null {
54+ const parts = pathname.split("/").filter(Boolean);
55+ if (parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
56+ if (parts.length === 1) return "overview";
57+ if (parts[1] !== "-" || parts.length !== 3) return null;
58+ return (TAB_PAGES as readonly string[]).includes(parts[2]!) ? (parts[2] as WorkspaceTabKey) : null;
59+}
60+
61+/** `?tab=` as people write it, from habit: the tab it means. */
62+const TAB_WORDS: Record<string, string> = {
63+ overview: "",
64+ projects: "-/projects",
65+ repositories: "-/projects",
66+ repos: "-/projects",
67+ packages: "-/packages",
68+ people: "-/people",
69+ members: "-/people",
70+ teams: "-/teams",
71+ insights: "-/insights",
72+};
73+
74+/** Workspace pages that moved, by their old name under `-/`. */
75+const MOVED: Record<string, string> = {
76+ members: "-/people",
77+ overview: "",
78+ "soon/teams": "-/teams",
79+ "soon/insights": "-/insights",
80+};
81+
82+/**
83+ * Where an old address of a workspace's pages is now, keeping its query;
84+ * null when it has not moved. `/<workspace>?tab=projects` and the like
85+ * open that tab.
86+ */
87+export function workspaceRedirect(pathname: string, search = ""): string | null {
88+ const parts = pathname.split("/").filter(Boolean);
89+ const slug = parts[0];
90+ if (!slug) return null;
91+ const params = new URLSearchParams(search);
92+ if (parts.length === 1 && params.has("tab")) {
93+ const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
94+ if (to === undefined) return null;
95+ params.delete("tab");
96+ const rest = params.toString();
97+ return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
98+ }
99+ if (parts[1] !== "-") return null;
100+ const to = MOVED[parts.slice(2).join("/")];
101+ if (to === undefined) return null;
102+ return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
103+}
+22−0
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+}
+15−12
4646 DropdownMenuSeparator,
4747 DropdownMenuTrigger,
4848 } from "./components/ui/dropdown-menu";
49−import { AppShell, Progress, type ShellData } from "./components/shell";
49+import { AppShell, Progress, type ShellData, useLeaving } from "./components/shell";
5050 import { SiteFooter } from "./components/footer";
5151 import { SpikeBanner } from "./components/spike-banner";
5252 import { readCookie } from "./lib/mission";
5858 import { countsFor, readableRepos } from "./lib/access.server";
5959 import { shortCache } from "./lib/cache.server";
6060 import { getViewer, viewerMiddleware } from "./lib/session.server";
61+import { shortcutOf, workspaceProjects } from "./lib/workspace-projects.server";
6162 import { registrationMode } from "./lib/registration.server";
6263 import { addresses } from "./lib/addresses.server";
6364 import { useSignUpCopy } from "./lib/registration";
140141 // something (lib/cache.server.ts).
141142 const kept = <T,>(what: string, load: () => Promise<T>) =>
142143 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),
145148 path ? countsFor(context, params) : Promise.resolve(null),
146149 // Whether billing is on, without reading the account: that asks the
147150 // card processor about the workspace's cards, too slow for every page.
152155 sharedRepos(user),
153156 // The bell's count: one query, never kept, so marking an item shows at once.
154157 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),
155160 ]);
156161 return {
157162 workspace,
158163 // 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),
167167 repo:
168168 path && counts?.ok
169169 ? {
469469 </SubmitButton>
470470 </Form>
471471 );
472+ const leaving = useLeaving();
472473 const banner =
473474 paused || verify ? (
474475 <>
498499 <Progress />
499500 <Header user={user} />
500501 {banner}
501− <div className="grow">{children}</div>
502+ <div {...leaving} className={`grow ${leaving.className}`}>
503+ {children}
504+ </div>
502505 <SiteFooter user={user} />
503506 </>
504507 )}
+9−0
6262 // A workspace's own pages sit under `-`, which no repository can be named.
6363 route(":owner", "routes/workspace/layout.tsx", [
6464 index("routes/workspace/overview.tsx"),
65+ // Its tabs: Overview (above), Projects, Packages, Teams, People, Insights.
66+ route("-/projects", "routes/workspace/projects.tsx"),
6567 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" }),
6675 route("-/tokens", "routes/workspace/tokens.tsx"),
6776 route("-/usage", "routes/workspace/usage.tsx"),
6877 route("-/billing", "routes/workspace/billing.tsx"),
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.