Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Merge branch 'worktree-agent-ac2922d9eb84a7909'

# Conflicts: # apps/web/app/components/home.tsx

syntaqxcommitted Parents04d55e38e6bb5eBrowse files
20 files+392−910/20 viewed
+5−5
184184 ### The Branches page
185185
186186 A repository's **Branches** tab, `g1t.sh/<workspace>/<repo>/branches`, lists
187−its branches under five tabs, kept in the address as `?tab=`:
187+its branches under five tabs, each at its own address:
188188
189189 | Tab | What it lists |
190190 | --- | --- |
191191 | **Overview** | The default branch, then the first five of each of the others below that has any: yours, active and stale, each with a link to the rest. |
192−| **Yours** (`?tab=yours`) | Branches whose latest commit is yours. |
193−| **Active** (`?tab=active`) | Branches with a commit in the last 90 days. |
194−| **Stale** (`?tab=stale`) | Branches with no commit in the last 90 days. |
195−| **All** (`?tab=all`) | Every branch but the default, newest commit first. |
192+| **Yours** (`/branches/yours`) | Branches whose latest commit is yours. |
193+| **Active** (`/branches/active`) | Branches with a commit in the last 90 days. |
194+| **Stale** (`/branches/stale`) | Branches with no commit in the last 90 days. |
195+| **All** (`/branches/all`) | Every branch but the default, newest commit first. |
196196
197197 **Search branches** narrows any tab by name (`?q=`). Each branch shows:
198198
+1−1
104104 ### Stars
105105
106106 Choose **Star** in the repository's header to keep it in your profile's
107−**Stars** tab, at `g1t.sh/u/<you>?tab=stars`. The number beside it leads
107+**Stars** tab, at `g1t.sh/u/<you>/stars`. The number beside it leads
108108 to who starred it. Anyone signed in who can read a repository can star it;
109109 stars on a private repository are seen only by people who can read it.
110110
+1−1
231231
232232 A file whose diff is longer than 1,000 lines (a lockfile or generated
233233 code, usually) waits until you press **Load diff**. A link to a file
234−(`#file-` and its path, such as `…/pull/12?tab=changes#file-src/app.ts`)
234+(`#file-` and its path, such as `…/pull/12/files#file-src/app.ts`)
235235 opens it and loads its lines at once, however long.
236236
237237 Some changes are too large to read in one go:
+2−1
969969 - **Pull requests** and **Issues:** everything you opened, and what g1t
970970 opened for you, newest first,
971971 with filters beside the list for state (open, closed, merged), type,
972− repository and sort order. Add `?tab=pulls&state=merged` and the like to
972+ repository and sort order. Each tab has its own address, such as `g1t.sh/u/<username>/pulls`;
973+ add `?state=merged` and the like to
973974 link to a filtered list.
974975
975976 **The contribution calendar.** The last year as a square a day, a column a
+1−1
340340 </details>
341341 )}
342342 <Link
343− to={`${base}/pull/${current.pull.number}?tab=session`}
343+ to={`${base}/pull/${current.pull.number}/session`}
344344 className="mt-4 inline-flex items-center gap-1 text-xs text-accent hover:underline"
345345 >
346346 The whole session
+14−16
6969 import { projectPages } from "../lib/chrome";
7070 import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings";
7171 import { type PinnableApp, sidebarCookie } from "../lib/apps";
72−import { REASON_FILTERS, inboxReason, inboxView } from "../lib/inbox";
72+import { REASON_FILTERS, inboxReason } from "../lib/inbox";
73+import { notificationViewAt, notificationsAddress, profilePath } from "../lib/tab-paths";
7374 import { GithubMark } from "./github";
7475 import { STATUS_URL, statusTitle } from "../lib/status";
7576 import { G1T_INVITES, leftBadge } from "../lib/invites";
11721173 const query = new URLSearchParams((going ?? location).search);
11731174 const atExplore = path === "/explore";
11741175 const fresh = atExplore && query.get("sort") === "new";
1175− const mine = `/u/${user.username}`;
1176− const atMine = path.toLowerCase() === mine.toLowerCase();
1177− const stars = atMine && query.get("tab") === "stars";
1176+ const mine = profilePath(user.username);
1177+ // A profile's tab is its third segment: `/u/ada/stars`.
1178+ const [, profileOf, profileTab] = path.toLowerCase().split("/").filter(Boolean);
1179+ const atMine = path.toLowerCase().startsWith("/u/") && profileOf === user.username.toLowerCase();
1180+ const stars = atMine && profileTab === "stars";
11781181 const shown = useBrowsedOwner(owner);
1179− const profileTab = query.get("tab");
11801182 const workspace = useRouteLoaderData("routes/workspace/layout") as
11811183 | { workspace: { slug: string }; projects?: { namespace: string; name: string; title: string }[] }
11821184 | undefined;
12301232 </NavRows>
12311233 <Rule />
12321234 <NavRows>
1233− <SidebarLink to={`${mine}?tab=stars`} icon={<Star size={15} />} current={stars}>
1235+ <SidebarLink to={profilePath(user.username, "stars")} icon={<Star size={15} />} current={stars}>
12341236 Your stars
12351237 </SidebarLink>
12361238 <SidebarLink to={mine} icon={<CircleUserRound size={15} />} current={atMine && !stars}>
12501252 >
12511253 {shown.name}
12521254 </SidebarLink>
1253− <SidebarLink to={`/u/${owner.name}?tab=stars`} icon={<Star size={15} />} current={profileTab === "stars"}>
1255+ <SidebarLink to={profilePath(owner.name, "stars")} icon={<Star size={15} />} current={profileTab === "stars"}>
12541256 Stars
12551257 </SidebarLink>
12561258 </>
14681470 * and what is done; then why you were told, which narrows any of them.
14691471 */
14701472 function NotificationsSidebar({ counts }: { counts: InboxCounts | null }) {
1471− const { search } = useLocation();
1473+ const { pathname, search } = useLocation();
14721474 const params = new URLSearchParams(search);
1473− const view = inboxView(params.get("view"));
1475+ // Saved and Done are paths (`/notifications/saved`); the rest narrows them.
1476+ const view = notificationViewAt(pathname);
14741477 const reason = inboxReason(params.get("reason"));
14751478 const tab = params.get("tab");
1476− const address = (changes: Record<string, string | null>) => {
1477− const merged: Record<string, string | null> = { tab, view: view === "inbox" ? null : view, reason, ...changes };
1478− const next = new URLSearchParams();
1479− for (const [name, value] of Object.entries(merged)) if (value) next.set(name, value);
1480− const query = next.toString();
1481− return query ? `/notifications?${query}` : "/notifications";
1482− };
1479+ const address = (changes: { view?: string | null; reason?: string | null; before?: string | null }) =>
1480+ notificationsAddress({ tab, view, reason, ...changes });
14831481 const views = [
14841482 { view: "inbox", label: "Everything", icon: <Bell size={15} />, count: counts?.unread },
14851483 { view: "saved", label: "Saved", icon: <Bookmark size={15} /> },
+1−1
223223 ref: `#${pull.number}`,
224224 by: who(workOwner(pull).username),
225225 facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files }),
226− open: pull.issue != null ? `${base}/issues/${pull.issue}` : `${base}/pull/${pull.number}?tab=changes`,
226+ open: pull.issue != null ? `${base}/issues/${pull.issue}` : `${base}/pull/${pull.number}/files`,
227227 };
228228 };
229229 const pullAction = (repo: Repo, pull: Pull, quick: Omit<QuickAction, "to">): QuickAction => ({
+123−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ branchSegment,
6+ branchesPath,
7+ movedTab,
8+ movedView,
9+ notificationView,
10+ notificationViewAt,
11+ notificationsAddress,
12+ notificationsPath,
13+ profilePath,
14+ profileTab,
15+ pullPath,
16+ pullTab,
17+ rulesAt,
18+ rulesPath,
19+} from "./tab-paths.ts";
20+
21+const at = (address: string) => new URL(address, "https://g1t.sh");
22+
23+test("a profile's tabs are paths, and its overview is the profile", () => {
24+ assert.equal(profilePath("ada"), "/u/ada");
25+ assert.equal(profilePath("ada", "overview"), "/u/ada");
26+ assert.equal(profilePath("ada", "pulls"), "/u/ada/pulls");
27+ assert.equal(profilePath("ada", "issues"), "/u/ada/issues");
28+ assert.equal(profilePath("ada", "stars"), "/u/ada/stars");
29+ // A tab it never had (GitHub's repositories) is the overview.
30+ assert.equal(profilePath("ada", "repositories"), "/u/ada");
31+ assert.equal(profileTab(undefined), "overview");
32+ assert.equal(profileTab("pulls"), "pulls");
33+ assert.equal(profileTab("Stars"), "stars");
34+ // The overview has one address; anything else is no page.
35+ assert.equal(profileTab("overview"), null);
36+ assert.equal(profileTab("repositories"), null);
37+});
38+
39+test("an old profile address with ?tab= goes to the tab's path, keeping its filters", () => {
40+ const to = (address: string) => movedTab(at(address), (tab) => profilePath("ada", tab));
41+ assert.equal(to("/u/ada?tab=pulls"), "/u/ada/pulls");
42+ assert.equal(to("/u/ada?tab=pulls&state=merged"), "/u/ada/pulls?state=merged");
43+ assert.equal(to("/u/ada?state=open&tab=issues&repo=acme%2Fweb&sort=oldest"), "/u/ada/issues?state=open&repo=acme%2Fweb&sort=oldest");
44+ assert.equal(to("/u/ada?tab=STARS"), "/u/ada/stars");
45+ assert.equal(to("/u/ada?tab=overview"), "/u/ada");
46+ assert.equal(to("/u/ada?tab=repositories"), "/u/ada");
47+ // A click's data request: `_routes` is the router's, not the page's.
48+ assert.equal(to("/u/ada.data?tab=pulls&_routes=root,routes/user"), "/u/ada/pulls");
49+ // No ?tab=: nothing moved.
50+ assert.equal(to("/u/ada/pulls?state=open"), null);
51+ assert.equal(to("/u/ada"), null);
52+});
53+
54+test("a pull request's tabs are /session and /files", () => {
55+ assert.equal(pullPath("/acme/web", 12), "/acme/web/pull/12");
56+ assert.equal(pullPath("/acme/web", 12, "conversation"), "/acme/web/pull/12");
57+ assert.equal(pullPath("/acme/web", 12, "session"), "/acme/web/pull/12/session");
58+ assert.equal(pullPath("/acme/web", 12, "changes"), "/acme/web/pull/12/files");
59+ assert.equal(pullPath("/acme/web", "12", "files"), "/acme/web/pull/12/files");
60+ assert.equal(pullPath("/acme/web", 12, "commits"), "/acme/web/pull/12");
61+ assert.equal(pullTab(undefined), "conversation");
62+ assert.equal(pullTab("files"), "changes");
63+ assert.equal(pullTab("session"), "session");
64+ assert.equal(pullTab("Files"), "changes");
65+ assert.equal(pullTab("changes"), null);
66+ assert.equal(pullTab("conversation"), null);
67+ assert.equal(pullTab("commits"), null);
68+});
69+
70+test("an old pull request address with ?tab= goes to the tab's path, keeping the rest", () => {
71+ const to = (address: string) => movedTab(at(address), (tab) => pullPath("/acme/web", "12", tab));
72+ assert.equal(to("/acme/web/pull/12?tab=changes"), "/acme/web/pull/12/files");
73+ assert.equal(to("/acme/web/pull/12?tab=session"), "/acme/web/pull/12/session");
74+ assert.equal(to("/acme/web/pull/12?tab=conversation"), "/acme/web/pull/12");
75+ assert.equal(to("/acme/web/pull/12?tab=files"), "/acme/web/pull/12/files");
76+ assert.equal(to("/acme/web/pull/12?tab=changes&w=1"), "/acme/web/pull/12/files?w=1");
77+ assert.equal(to("/acme/web/pull/12"), null);
78+});
79+
80+test("the Branches page's tabs are paths", () => {
81+ assert.equal(branchesPath("/acme/web"), "/acme/web/branches");
82+ assert.equal(branchesPath("/acme/web", "stale"), "/acme/web/branches/stale");
83+ assert.equal(branchesPath("/acme/web", "nope"), "/acme/web/branches");
84+ assert.equal(branchSegment(undefined), "overview");
85+ assert.equal(branchSegment("yours"), "yours");
86+ assert.equal(branchSegment("overview"), null);
87+ assert.equal(branchSegment("main"), null);
88+ const to = (address: string) => movedTab(at(address), (tab) => branchesPath("/acme/web", tab));
89+ assert.equal(to("/acme/web/branches?tab=stale&q=fix"), "/acme/web/branches/stale?q=fix");
90+ assert.equal(to("/acme/web/branches?tab=overview"), "/acme/web/branches");
91+});
92+
93+test("rules' Insights is a path, for a repository and a workspace", () => {
94+ assert.equal(rulesPath("/acme/web/settings/rules"), "/acme/web/settings/rules");
95+ assert.equal(rulesPath("/acme/web/settings/rules", "insights"), "/acme/web/settings/rules/insights");
96+ assert.equal(rulesPath("/acme/-/rules", "insights"), "/acme/-/rules/insights");
97+ assert.equal(rulesAt("/acme/web/settings/rules/insights"), "insights");
98+ assert.equal(rulesAt("/acme/web/settings/rules/insights.data"), "insights");
99+ assert.equal(rulesAt("/acme/-/rules/insights"), "insights");
100+ assert.equal(rulesAt("/acme/web/settings/rules"), "rulesets");
101+ assert.equal(rulesAt("/acme/web/settings/rules.data"), "rulesets");
102+ const to = (address: string) => movedTab(at(address), (tab) => rulesPath("/acme/web/settings/rules", tab));
103+ assert.equal(to("/acme/web/settings/rules?tab=insights&before=abc"), "/acme/web/settings/rules/insights?before=abc");
104+ assert.equal(to("/acme/web/settings/rules?tab=rulesets&branch=main"), "/acme/web/settings/rules?branch=main");
105+});
106+
107+test("notifications' Saved and Done are paths; the severity tabs and reasons narrow them", () => {
108+ assert.equal(notificationsPath(), "/notifications");
109+ assert.equal(notificationsPath("saved"), "/notifications/saved");
110+ assert.equal(notificationsPath("done"), "/notifications/done");
111+ assert.equal(notificationsPath("nope"), "/notifications");
112+ assert.equal(notificationView(undefined), "inbox");
113+ assert.equal(notificationView("done"), "done");
114+ assert.equal(notificationView("inbox"), null);
115+ assert.equal(notificationViewAt("/notifications/saved"), "saved");
116+ assert.equal(notificationViewAt("/notifications"), "inbox");
117+ assert.equal(notificationsAddress({ view: "done", tab: "needs", reason: "mention" }), "/notifications/done?tab=needs&reason=mention");
118+ assert.equal(notificationsAddress({ view: "inbox", tab: "all", reason: null, before: null }), "/notifications");
119+ assert.equal(notificationsAddress({ view: null, before: "c1" }), "/notifications?before=c1");
120+ assert.equal(movedView(at("/notifications?view=done&reason=mention")), "/notifications/done?reason=mention");
121+ assert.equal(movedView(at("/notifications?view=inbox&tab=needs")), "/notifications?tab=needs");
122+ assert.equal(movedView(at("/notifications?tab=needs")), null);
123+});
+148−0
1+/**
2+ * Pages that are tabs of one page keep their tab in the path, not in
3+ * `?tab=`: `/u/ada/pulls`, `/acme/web/pull/12/files`,
4+ * `/acme/web/branches/stale`, `/acme/web/settings/rules/insights`,
5+ * `/notifications/saved`. What narrows a list (state, q, sort, a cursor)
6+ * stays in the query.
7+ *
8+ * Addresses from before still say `?tab=`: `movedTab` is where one goes
9+ * now, the rest of its query kept, so each page's loader sends it on (301).
10+ * No Workers or DOM imports, so it is tested under Node.
11+ */
12+
13+/** Where an address that still says `?tab=` goes now, or null when it says no `?tab=`. */
14+export function movedTab(url: URL, pathFor: (tab: string) => string): string | null {
15+ const tab = url.searchParams.get("tab");
16+ if (tab == null) return null;
17+ return `${pathFor(tab.toLowerCase())}${rest(url, "tab")}`;
18+}
19+
20+/**
21+ * The query an address keeps once `moved` is in its path, with its `?`.
22+ * A click asks for a page's data at `<path>.data?_routes=…`; the address
23+ * sent on is the page's own, which the browser then asks for as usual.
24+ */
25+function rest(url: URL, moved: string): string {
26+ const kept = new URLSearchParams(url.searchParams);
27+ kept.delete(moved);
28+ kept.delete("_routes");
29+ const query = kept.toString();
30+ return query ? `?${query}` : "";
31+}
32+
33+/** The segment `value` names among `tabs`, the first (the page's own address) for nothing, null for anything else. */
34+function segmentTab<T extends string>(tabs: readonly T[], value: string | undefined): T | null {
35+ if (value == null || value === "") return tabs[0];
36+ return tabs.find((tab) => tab !== tabs[0] && tab === value.toLowerCase()) ?? null;
37+}
38+
39+// --- A person's profile -----------------------------------------------------------
40+
41+export const PROFILE_TABS = ["overview", "pulls", "issues", "stars"] as const;
42+export type ProfileTab = (typeof PROFILE_TABS)[number];
43+
44+/** `/u/<username>`, or one of its tabs: `/u/<username>/pulls`. */
45+export function profilePath(username: string, tab: ProfileTab | string = "overview"): string {
46+ const known = (PROFILE_TABS as readonly string[]).includes(tab) ? tab : "overview";
47+ return known === "overview" ? `/u/${username}` : `/u/${username}/${known}`;
48+}
49+
50+/** The profile tab a path segment names; null for one it does not (a 404). */
51+export function profileTab(segment: string | undefined): ProfileTab | null {
52+ return segmentTab(PROFILE_TABS, segment);
53+}
54+
55+// --- A pull request ---------------------------------------------------------------
56+
57+export const PULL_TABS = ["conversation", "session", "changes"] as const;
58+export type PullTab = (typeof PULL_TABS)[number];
59+
60+/** Each tab's segment: Files changed is `/files`. */
61+const PULL_SEGMENTS: Record<PullTab, string> = { conversation: "", session: "/session", changes: "/files" };
62+
63+/** `<repo>/pull/<n>`, or one of its tabs: `<repo>/pull/<n>/files`. `repo` is `/<owner>/<repo>`. */
64+export function pullPath(repo: string, number: number | string, tab: PullTab | string = "conversation"): string {
65+ const known: PullTab = tab === "files" ? "changes" : ((PULL_TABS as readonly string[]).includes(tab) ? (tab as PullTab) : "conversation");
66+ return `${repo}/pull/${number}${PULL_SEGMENTS[known]}`;
67+}
68+
69+/** The pull request tab a path segment names; null for one it does not (a 404). */
70+export function pullTab(segment: string | undefined): PullTab | null {
71+ if (segment == null || segment === "") return "conversation";
72+ const value = segment.toLowerCase();
73+ if (value === "files") return "changes";
74+ if (value === "session") return "session";
75+ return null;
76+}
77+
78+// --- A repository's branches ------------------------------------------------------
79+
80+/** The Branches page's tabs, as lib/branches.ts's BRANCH_TABS. */
81+const BRANCH_SEGMENTS = ["overview", "yours", "active", "stale", "all"] as const;
82+
83+/** `<repo>/branches`, or one of its tabs: `<repo>/branches/stale`. */
84+export function branchesPath(repo: string, tab: string = "overview"): string {
85+ const known = (BRANCH_SEGMENTS as readonly string[]).includes(tab) ? tab : "overview";
86+ return known === "overview" ? `${repo}/branches` : `${repo}/branches/${known}`;
87+}
88+
89+/** The Branches tab a path segment names; null for one it does not (a 404). */
90+export function branchSegment(segment: string | undefined): (typeof BRANCH_SEGMENTS)[number] | null {
91+ return segmentTab(BRANCH_SEGMENTS, segment);
92+}
93+
94+// --- Rules: a repository's or a workspace's ---------------------------------------
95+
96+export type RulesTab = "rulesets" | "insights";
97+
98+/** The rules page (`<repo>/settings/rules`, `/<workspace>/-/rules`), or its Insights: `…/rules/insights`. */
99+export function rulesPath(rules: string, tab: RulesTab | string = "rulesets"): string {
100+ return tab === "insights" ? `${rules}/insights` : rules;
101+}
102+
103+/** The rules tab a page's path is on: `…/rules/insights` is Insights (its data at `…/insights.data`). */
104+export function rulesAt(pathname: string): RulesTab {
105+ return /\/rules\/insights(\.data)?\/?$/.test(pathname) ? "insights" : "rulesets";
106+}
107+
108+// --- Notifications ----------------------------------------------------------------
109+
110+export const NOTIFICATION_VIEWS = ["inbox", "saved", "done"] as const;
111+export type NotificationView = (typeof NOTIFICATION_VIEWS)[number];
112+
113+/** `/notifications` (Everything), `/notifications/saved` or `/notifications/done`. */
114+export function notificationsPath(view: NotificationView | string = "inbox"): string {
115+ return view === "saved" || view === "done" ? `/notifications/${view}` : "/notifications";
116+}
117+
118+/** The notifications view a path segment names; null for one it does not (a 404). */
119+export function notificationView(segment: string | undefined): NotificationView | null {
120+ return segmentTab(NOTIFICATION_VIEWS, segment);
121+}
122+
123+/**
124+ * A notifications address: the view as its path, and what narrows it
125+ * (`tab`, the severity tabs; `reason`; `before`, a page's cursor) as its
126+ * query. Empty values, and the `all` tab, are left out.
127+ */
128+export function notificationsAddress(state: { view?: string | null; tab?: string | null; reason?: string | null; before?: string | null }): string {
129+ const query = new URLSearchParams();
130+ if (state.tab && state.tab !== "all") query.set("tab", state.tab);
131+ if (state.reason) query.set("reason", state.reason);
132+ if (state.before) query.set("before", state.before);
133+ const text = query.toString();
134+ return `${notificationsPath(state.view ?? "inbox")}${text ? `?${text}` : ""}`;
135+}
136+
137+/** The view a notifications page's path is on: `/notifications/saved` is Saved. */
138+export function notificationViewAt(pathname: string): NotificationView {
139+ const [first, second] = pathname.split("/").filter(Boolean);
140+ return first === "notifications" ? (notificationView(second) ?? "inbox") : "inbox";
141+}
142+
143+/** Where a notifications address that still says `?view=` goes now, or null. */
144+export function movedView(url: URL): string | null {
145+ const view = url.searchParams.get("view");
146+ if (view == null) return null;
147+ return `${notificationsPath(view.toLowerCase())}${rest(url, "view")}`;
148+}
+12−6
4949 ]),
5050 // The account menu's header: name, primary email and invites left.
5151 route("settings/menu.json", "routes/settings-menu-json.ts"),
52− // People, apart from workspaces: `u` is a reserved name.
53− route("u/:username", "routes/user.tsx"),
52+ // People, apart from workspaces: `u` is a reserved name. Their profile's
53+ // tabs are paths (`/u/ada/pulls`); `?tab=` is sent on (lib/tab-paths.ts).
54+ route("u/:username/:tab?", "routes/user.tsx"),
5455 // The card that opens over a person's name or avatar (lib/hovercard.ts).
5556 // `-` is no workspace's name, so nothing else is ever found here.
5657 route("-/hovercard/user/:username", "routes/hovercard-user.ts"),
6768 route("pricing", "routes/pricing.tsx"),
6869 route("search", "routes/search.tsx"),
6970 // Each person's notifications, what the panel in the top bar fetches,
70− // and the address the page once had.
71− route("notifications", "routes/notifications.tsx"),
71+ // and the address the page once had. Saved and Done are
72+ // `/notifications/saved` and `/notifications/done`.
73+ route("notifications/:view?", "routes/notifications.tsx"),
7274 route("inbox", "routes/inbox-moved.ts"),
7375 route("inbox.json", "routes/inbox-json.ts"),
7476 // What the command palette shows as someone types.
220222 route("-/audit/export", "routes/workspace/audit-export.ts"),
221223 route("-/guardrails", "routes/workspace/guardrails.tsx"),
222224 route("-/rules", "routes/workspace/rules.tsx"),
225+ route("-/rules/insights", "routes/workspace/rules.tsx", { id: "routes/workspace/rules-insights" }),
223226 route("-/rules/:id", "routes/workspace/ruleset.tsx"),
224227 // What the workspace will have across its projects.
225228 route("-/soon/:feature", "routes/workspace/soon.tsx"),
262265 route("blob/:ref/*", "routes/repo/blob.tsx"),
263266 route("archive/*", "routes/repo/archive.ts"),
264267 route("commits", "routes/repo/commits.tsx"),
265− route("branches", "routes/repo/branches.tsx"),
268+ // Its tabs are paths: `branches/yours`, `branches/stale`, ….
269+ route("branches/:tab?", "routes/repo/branches.tsx"),
266270 route("tags", "routes/repo/tags.tsx"),
267271 route("releases", "routes/repo/releases.tsx"),
268272 route("releases/new", "routes/repo/release-new.tsx"),
286290 route("issues/:number", "routes/repo/issue.tsx"),
287291 route("pulls", "routes/repo/pulls.tsx"),
288292 route("pulls/new", "routes/repo/pull-new.tsx"),
289− route("pull/:number", "routes/repo/pull.tsx"),
293+ // Its tabs are paths: `pull/12/session`, `pull/12/files`.
294+ route("pull/:number/:tab?", "routes/repo/pull.tsx"),
290295 route("queue", "routes/repo/queue.tsx"),
291296 // Who is working on what, live: claims, harness sessions, the queue, coordination events.
292297 route("work", "routes/repo/work.tsx"),
344349 route("settings/keys", "routes/repo/settings-deploy-keys.tsx"),
345350 route("settings/branches", "routes/repo/settings-branches.tsx"),
346351 route("settings/rules", "routes/repo/settings-rules.tsx"),
352+ route("settings/rules/insights", "routes/repo/settings-rules.tsx", { id: "routes/repo/settings-rules-insights" }),
347353 route("settings/rules/:id", "routes/repo/settings-ruleset.tsx"),
348354 route("settings/webhooks", "routes/repo/webhooks.tsx"),
349355 route("settings/secrets", "routes/repo/secrets.tsx"),
+4−1
1−/** `/inbox` is Notifications now: the page, with the same query. */
1+/** `/inbox` is Notifications now: the page, with the same query (`?view=done` is `/notifications/done`). */
22 import { redirect } from "react-router";
33
44 import type { Route } from "./+types/inbox-moved";
5+import { movedView } from "../lib/tab-paths";
56
67 export function loader({ request }: Route.LoaderArgs) {
78 const url = new URL(request.url);
89 url.searchParams.delete("_routes");
10+ const view = movedView(url);
11+ if (view) return redirect(view, 301);
912 const query = url.searchParams.toString();
1013 return redirect(`/notifications${query ? `?${query}` : ""}`, 301);
1114 }
+13−11
66 * them too. Every notifications form posts here, the panel's included.
77 */
88 import { Bell, Bookmark, Check } from "lucide-react";
9−import { Link, data, useNavigate } from "react-router";
9+import { Link, data, redirect, useNavigate } from "react-router";
1010
1111 import type { Route } from "./+types/notifications";
1212 import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox";
1313 import { Card } from "../components/ui/card";
1414 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
1515 import { cn } from "../lib/cn";
16−import { REASON_FILTERS, inboxReason, inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox";
16+import { REASON_FILTERS, inboxReason, inboxTab, markFromForm, severityOf, tabCount } from "../lib/inbox";
17+import { movedView, notificationView, notificationsAddress } from "../lib/tab-paths";
18+import { notFound } from "../lib/not-found.server";
1719 import { page } from "../lib/meta";
1820 import { inbox } from "../lib/services.server";
1921 import { assertSameOrigin, requireUser } from "../lib/session.server";
2426 return page(args, { title: "Notifications · g1t" });
2527 }
2628
27−export async function loader({ request, context }: Route.LoaderArgs) {
29+export async function loader({ request, context, params }: Route.LoaderArgs) {
2830 const user = requireUser(context, request);
2931 const url = new URL(request.url);
32+ // Saved and Done are paths: `?view=done`, the address before, is `/notifications/done`.
33+ const moved = movedView(url);
34+ if (moved) throw redirect(moved, 301);
35+ const view = notificationView(params.view);
36+ if (!view) throw notFound("page");
37+ // What narrows a view (the severity tabs, why you were told, a page) stays in the query.
3038 const tab = inboxTab(url.searchParams.get("tab"));
31− const view = inboxView(url.searchParams.get("view"));
3239 const reason = inboxReason(url.searchParams.get("reason"));
3340 const before = url.searchParams.get("before");
3441 const [list, counts] = await Promise.all([
6067 export default function NotificationsPage({ loaderData }: Route.ComponentProps) {
6168 const { tab, view, reason, before, items, next, counts } = loaderData;
6269 const navigate = useNavigate();
63− const address = (changes: Record<string, string | null>) => {
64− const params = new URLSearchParams();
65− const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, reason, ...changes };
66− for (const [name, value] of Object.entries(merged)) if (value) params.set(name, value);
67− const query = params.toString();
68− return query ? `/notifications?${query}` : "/notifications";
69− };
70+ const address = (changes: { view?: string | null; tab?: string | null; reason?: string | null; before?: string | null }) =>
71+ notificationsAddress({ tab, view, reason, ...changes });
7072
7173 return (
7274 <main className="mx-auto max-w-3xl px-4 py-8 sm:px-6">
+13−8
11 import { Search, X } from "lucide-react";
22 import { useEffect, useMemo, useState } from "react";
3−import { Form, Link, useNavigate } from "react-router";
3+import { Form, Link, redirect, useNavigate } from "react-router";
44
55 import type { Route } from "./+types/branches";
66 import type { ActiveBranch } from "../../components/branches";
1818 import { Input } from "../../components/ui/input";
1919 import { Tabs, TabsList, TabsTrigger } from "../../components/ui/tabs";
2020 import { refusal, requireRepo } from "../../lib/access.server";
21−import { type BranchTab, branchSections, branchTab, driftScale } from "../../lib/branches";
21+import { type BranchTab, branchSections, driftScale } from "../../lib/branches";
22+import { notFound } from "../../lib/not-found.server";
23+import { branchSegment, branchesPath, movedTab } from "../../lib/tab-paths";
2224 import { readBranches } from "../../lib/branches.server";
2325 import { repoPages } from "../../lib/repo-live";
2426 import { useRepoLive } from "../../lib/use-repo-live";
3739 }
3840
3941 export async function loader({ params, context, request }: Route.LoaderArgs) {
42+ const url = new URL(request.url);
43+ // Its tabs are paths: `?tab=stale`, the address before, is `/branches/stale`.
44+ const moved = movedTab(url, (asked) => branchesPath(`/${params.owner}/${params.repo}`, asked));
45+ if (moved) throw redirect(moved, 301);
46+ const tab: BranchTab | null = branchSegment(params.tab);
47+ if (!tab) throw notFound("page");
48+ if (params.tab != null && params.tab !== tab) throw redirect(`${branchesPath(`/${params.owner}/${params.repo}`, tab)}${url.search}`);
4049 const { viewer, repo, access } = await requireRepo(context, params, "read");
4150 const path = { namespace: params.owner, name: params.repo };
42− const url = new URL(request.url);
43− const tab = branchTab(url.searchParams.get("tab"));
4451 const query = url.searchParams.get("q")?.trim().toLowerCase() || null;
4552 const soft = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);
4653 // Admins are told when the default branch has no rules at all.
171178
172179 const tabTo = (next: string, keepQuery = true) => {
173180 const search = new URLSearchParams();
174− if (next !== "overview") search.set("tab", next);
175181 if (query && keepQuery) search.set("q", query);
176182 const qs = search.toString();
177− return `${base}/branches${qs ? `?${qs}` : ""}`;
183+ return `${branchesPath(base, next)}${qs ? `?${qs}` : ""}`;
178184 };
179185
180186 return (
200206 </TabsList>
201207 </Tabs>
202208 </div>
203− <Form method="get" role="search" className="relative w-full sm:w-72">
204− {tab !== "overview" && <input type="hidden" name="tab" value={tab} />}
209+ <Form method="get" action={branchesPath(base, tab)} role="search" className="relative w-full sm:w-72">
205210 <Search size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
206211 <Input name="q" defaultValue={query ?? ""} placeholder="Search branches" aria-label="Search branches" className="h-8 pl-8" />
207212 </Form>
+1−1
175175 </Badge>
176176 )}
177177 <Link
178− to={`${base}/pull/${pull.number}?tab=session`}
178+ to={`${base}/pull/${pull.number}/session`}
179179 className="ml-auto flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
180180 >
181181 <MessagesSquare size={13} />
+15−8
9393 import { ViolationList } from "../../components/rules";
9494 import { PullCodeOwnersPanel, TeamReviewer } from "../../components/codeowners";
9595 import { notFound } from "../../lib/not-found.server";
96+import { type PullTab, movedTab, pullPath, pullTab } from "../../lib/tab-paths";
9697 import { actions, deployments, identity, inbox, repos, securitySuite, work } from "../../lib/services.server";
9798 import { commitChecksFor } from "../../lib/commit-checks.server";
9899 import { CommitChecksBadge } from "../../components/commit-checks";
105106 import { useRepoLive } from "../../lib/use-repo-live";
106107
107108 const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
108−const TABS = ["conversation", "session", "changes"] as const;
109−type Tab = (typeof TABS)[number];
109+type Tab = PullTab;
110110
111111 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
112112 const pull = loaderData?.pull;
129129 const viewer = getViewer(context);
130130 const path = { namespace: params.owner, name: params.repo };
131131 const number = Number(params.number);
132− const asked = new URL(request.url).searchParams.get("tab");
133− const tab: Tab = TABS.find((name) => name === asked) ?? "conversation";
132+ // Its tabs are paths: `?tab=changes`, the address before, is `/pull/12/files`.
133+ const url = new URL(request.url);
134+ const moved = movedTab(url, (asked) => pullPath(`/${params.owner}/${params.repo}`, params.number, asked));
135+ if (moved) throw redirect(moved, 301);
136+ const tab: Tab | null = pullTab(params.tab);
137+ if (!tab) throw notFound("page");
138+ if (params.tab != null && params.tab !== params.tab.toLowerCase()) {
139+ throw redirect(`${pullPath(`/${params.owner}/${params.repo}`, params.number, tab)}${url.search}`);
140+ }
134141
135142 // Members of the workspace pick reviewers and assignees from its people;
136143 // what the viewer may do here goes by their role on the repository.
857864 const failure = active ? standingFailure(decisions, pull.number) : null;
858865 const answered = failure ? resolutionOf(decisions, failure).fix : null;
859866 const review = {
860− changesUrl: here + "?tab=changes",
867+ changesUrl: pullPath(base, pull.number, "changes"),
861868 // Nobody reviews their own pull request, nor one g1t made for them.
862869 canJudge: active && viewer != null && viewer.id !== workOwner(pull).id,
863870 };
10031010 Conversation
10041011 <TabCount n={comments.length} />
10051012 </TabLink>
1006− <TabLink to={here + "?tab=session"} active={tab === "session"}>
1013+ <TabLink to={pullPath(base, pull.number, "session")} active={tab === "session"}>
10071014 <MessagesSquare size={15} />
10081015 Session
10091016 </TabLink>
1010− <TabLink to={here + "?tab=changes"} active={tab === "changes"}>
1017+ <TabLink to={pullPath(base, pull.number, "changes")} active={tab === "changes"}>
10111018 <FileDiff size={15} />
10121019 Files changed
10131020 <TabCount n={pull.files.length} />
13571364 owner={params.owner}
13581365 repo={params.repo}
13591366 defaultBranch={defaultBranch}
1360− changesUrl={here + "?tab=changes"}
1367+ changesUrl={pullPath(base, pull.number, "changes")}
13611368 error={catchUp && "error" in catchUp ? catchUp.error : null}
13621369 />
13631370 ) : probing ? (
+9−7
11 import { ChartColumn, Plus, Search, ShieldCheck } from "lucide-react";
2−import { Form, Link, useSearchParams } from "react-router";
2+import { Form, Link, redirect, useSearchParams } from "react-router";
33
44 import type { Route } from "./+types/settings-rules";
55 import { page } from "../../lib/meta";
1111 import { work } from "../../lib/services.server";
1212 import { getViewer, unwrap } from "../../lib/session.server";
1313 import { requireInsider } from "../../lib/access.server";
14+import { movedTab, rulesAt, rulesPath } from "../../lib/tab-paths";
1415
1516 export function meta({ params, ...args }: Route.MetaArgs) {
1617 return page(args, { title: `Rules · ${params.owner}/${params.repo} · g1t` });
1819
1920 export async function loader({ params, context, request }: Route.LoaderArgs) {
2021 const viewer = getViewer(context);
22+ const url = new URL(request.url);
23+ // Insights is a path: `?tab=insights`, the address before, is `/settings/rules/insights`.
24+ const moved = movedTab(url, (asked) => rulesPath(`/${params.owner}/${params.repo}/settings/rules`, asked));
25+ if (moved) throw redirect(moved, 301);
2126 // Admin, as for branch protection.
2227 const { repo, access } = await requireInsider(context, params, "manage_protection");
23− const url = new URL(request.url);
24− const tab = url.searchParams.get("tab") === "insights" ? "insights" : "rulesets";
28+ const tab = rulesAt(url.pathname);
2529 const path = { namespace: params.owner, name: params.repo };
2630 const tagged = url.searchParams.get("tag");
2731 const branch = (tagged ?? url.searchParams.get("branch") ?? repo.defaultBranch).trim() || repo.defaultBranch;
5761 const tabLink = (name: "rulesets" | "insights") => {
5862 const next = new URLSearchParams(search);
5963 next.delete("before");
60− if (name === "rulesets") next.delete("tab");
61− else next.set("tab", name);
6264 const query = next.toString();
63− return `${base}/settings/rules${query ? `?${query}` : ""}`;
65+ return `${rulesPath(`${base}/settings/rules`, name)}${query ? `?${query}` : ""}`;
6466 };
6567 return (
6668 <div className="max-w-4xl">
140142 </section>
141143 </div>
142144 ) : evaluations ? (
143− <InsightsView page={evaluations} olderHref={evaluations.next ? `${base}/settings/rules?tab=insights&before=${encodeURIComponent(evaluations.next)}` : null} />
145+ <InsightsView page={evaluations} olderHref={evaluations.next ? `${base}/settings/rules/insights?before=${encodeURIComponent(evaluations.next)}` : null} />
144146 ) : (
145147 <p className="text-sm text-muted">Insights could not be read just now.</p>
146148 )}
+1−1
101101 for #{pull.issue}
102102 </Link>
103103 )}
104− <Link to={`${base}/pull/${pull.number}?tab=session`} className="hover:text-fg">
104+ <Link to={`${base}/pull/${pull.number}/session`} className="hover:text-fg">
105105 Why
106106 </Link>
107107 {pull.mergedBy && (
+19−15
3838 import { IssueIcon, PullIcon } from "../components/work-icons";
3939 import { lastDay } from "../lib/contribution-calendar";
4040 import { notFound } from "../lib/not-found.server";
41+import { type ProfileTab, movedTab, profilePath, profileTab } from "../lib/tab-paths";
4142 import { identity, projects, repos, work } from "../lib/services.server";
4243 import { getViewer } from "../lib/session.server";
4344
44−type Tab = "overview" | "pulls" | "issues" | "stars";
45+type Tab = ProfileTab;
4546
4647 const EMPTY: Authored = {
4748 items: [],
7980 const username = params.username.toLowerCase();
8081 const profile = await identity.profile(username);
8182 if (!profile) throw notFound("person");
83+ // Its tabs are paths: `?tab=pulls`, the address before, is `/u/ada/pulls`.
84+ const moved = movedTab(url, (asked) => profilePath(profile.username, asked));
85+ if (moved) throw redirect(moved, 301);
86+ const tab: Tab | null = profileTab(params.tab);
87+ if (!tab) throw notFound("page");
8288 // One address per person: `/u/Ada` is `/u/ada`.
83− if (params.username !== profile.username) {
84− throw redirect(`/u/${profile.username}${url.search}`);
89+ if (params.username !== profile.username || (params.tab != null && params.tab !== tab)) {
90+ throw redirect(`${profilePath(profile.username, tab)}${url.search}`);
8591 }
8692
8793 const query = url.searchParams;
88− const tab: Tab = pick(query.get("tab"), ["pulls", "issues", "stars"] as const) ?? "overview";
8994 const state = pick(query.get("state"), (tab === "pulls" ? ["open", "closed", "merged"] : ["open", "closed"]) as AuthoredState[]);
9095 const sort = pick(query.get("sort"), ["updated", "oldest"] as const satisfies AuthoredSort[]);
9196 const repo = query.get("repo")?.trim() || undefined;
281286 }) {
282287 const item = (value: Tab, label: string, icon: ReactNode, count?: number) => (
283288 <Link
284− to={value === "overview" ? `/u/${username}` : `/u/${username}?tab=${value}`}
289+ to={profilePath(username, value)}
285290 aria-current={tab === value ? "page" : undefined}
286291 preventScrollReset
287292 className={`-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${
363368 label="Pull requests merged"
364369 value={counts.pullsMerged}
365370 icon={<GitMerge size={15} className="text-merged" />}
366− to={`/u/${profile.username}?tab=pulls&state=merged`}
371+ to={`${profilePath(profile.username, "pulls")}?state=merged`}
367372 />
368373 <Stat
369374 label="Open pull requests"
370375 value={counts.pullsOpen}
371376 icon={<GitPullRequest size={15} className="text-success" />}
372− to={`/u/${profile.username}?tab=pulls&state=open`}
377+ to={`${profilePath(profile.username, "pulls")}?state=open`}
373378 />
374379 <Stat
375380 label="Issues opened"
376381 value={counts.issues}
377382 icon={<CircleDot size={15} className="text-success" />}
378− to={`/u/${profile.username}?tab=issues`}
383+ to={profilePath(profile.username, "issues")}
379384 />
380385 </dl>
381386
394399 {(counts.pulls > 0 || counts.issues > 0) && (
395400 <p className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm">
396401 {counts.pulls > 0 && (
397− <Link to={`/u/${profile.username}?tab=pulls`} className="text-muted hover:text-fg">
402+ <Link to={profilePath(profile.username, "pulls")} className="text-muted hover:text-fg">
398403 All {counts.pulls} pull {counts.pulls === 1 ? "request" : "requests"} →
399404 </Link>
400405 )}
401406 {counts.issues > 0 && (
402− <Link to={`/u/${profile.username}?tab=issues`} className="text-muted hover:text-fg">
407+ <Link to={profilePath(profile.username, "issues")} className="text-muted hover:text-fg">
403408 All {counts.issues} {counts.issues === 1 ? "issue" : "issues"} →
404409 </Link>
405410 )}
498503 const pulls = tab === "pulls";
499504 const noun = pulls ? "pull requests" : "issues";
500505 const filtered = filter.state != null || filter.repo != null;
501− const base = `/u/${profile.username}?tab=${tab}`;
506+ const base = profilePath(profile.username, tab);
502507 const [params] = useSearchParams();
503508 const next = new URLSearchParams(params);
504509 if (activity.next) next.set("before", activity.next);
596601 return (
597602 <aside aria-label="Filters" className="min-w-0 lg:order-2">
598603 <Card asChild tone="plain" className="space-y-5 p-4 lg:sticky lg:top-20">
599− <Form method="get">
600− <input type="hidden" name="tab" value={tab} />
604+ <Form method="get" action={profilePath(username, tab)}>
601605 <FilterGroup title="Type">
602606 <RadioGroup
603607 value={tab}
604− onValueChange={(value) => void navigate(`/u/${username}?tab=${value}`, { preventScrollReset: true })}
608+ onValueChange={(value) => void navigate(profilePath(username, value), { preventScrollReset: true })}
605609 aria-label="Type"
606610 >
607611 <RadioOption value="pulls" label={<Counted label="Pull requests" count={counts.pulls} />} />
670674 </Button>
671675 </noscript>
672676 {filtered && (
673− <Link to={`/u/${username}?tab=${tab}`} preventScrollReset className="text-xs text-muted hover:text-fg">
677+ <Link to={profilePath(username, tab)} preventScrollReset className="text-xs text-muted hover:text-fg">
674678 Clear filters
675679 </Link>
676680 )}
+8−6
11 import { ChartColumn, Plus, ShieldCheck } from "lucide-react";
2−import { Link, data, useSearchParams } from "react-router";
2+import { Link, data, redirect, useSearchParams } from "react-router";
33
44 import type { Route } from "./+types/rules";
55 import { page } from "../../lib/meta";
77 import { ButtonLink } from "../../components/ui";
88 import { work } from "../../lib/services.server";
99 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
10+import { movedTab, rulesAt, rulesPath } from "../../lib/tab-paths";
1011
1112 export function meta({ params, ...args }: Route.MetaArgs) {
1213 return page(args, { title: `Rules · ${params.owner} · g1t` });
1718 const role = roleIn(viewer, params.owner);
1819 if (!role) throw data(null, { status: 404 });
1920 const url = new URL(request.url);
20− const tab = url.searchParams.get("tab") === "insights" ? "insights" : "rulesets";
21+ // Insights is a path: `?tab=insights`, the address before, is `/-/rules/insights`.
22+ const moved = movedTab(url, (asked) => rulesPath(`/${params.owner}/-/rules`, asked));
23+ if (moved) throw redirect(moved, 301);
24+ const tab = rulesAt(url.pathname);
2125 const owner = { workspace: params.owner };
2226 const [rulesets, evaluations] = await Promise.all([
2327 work.listRulesets(owner, viewer),
3842 const tabLink = (name: "rulesets" | "insights") => {
3943 const next = new URLSearchParams(search);
4044 next.delete("before");
41− if (name === "rulesets") next.delete("tab");
42− else next.set("tab", name);
4345 const query = next.toString();
44− return `${base}${query ? `?${query}` : ""}`;
46+ return `${rulesPath(base, name)}${query ? `?${query}` : ""}`;
4547 };
4648 return (
4749 <div className="mx-auto max-w-4xl">
8385 />
8486 </section>
8587 ) : evaluations ? (
86− <InsightsView page={evaluations} showRepository olderHref={evaluations.next ? `${base}?tab=insights&before=${encodeURIComponent(evaluations.next)}` : null} />
88+ <InsightsView page={evaluations} showRepository olderHref={evaluations.next ? `${rulesPath(base, "insights")}?before=${encodeURIComponent(evaluations.next)}` : null} />
8789 ) : (
8890 <p className="text-sm text-muted">Insights could not be read just now.</p>
8991 )}
+1−1
453453 // The pull request's own changes: its head may exist only in its fork.
454454 let target_url = checked
455455 .as_ref()
456− .map(|_| format!("/{}/{}/pull/{}?tab=changes", repo.namespace, repo.name, pull.number));
456+ .map(|_| format!("/{}/{}/pull/{}/files", repo.namespace, repo.name, pull.number));
457457 self.set_commit_status(SetCommitStatusArgs {
458458 repo_id: repo.id.clone(),
459459 sha: head.to_owned(),

Comments

Sign in to comment.