| 1 | /** |
| 2 | * Links into the app. A `g1t://` link names a site path, as on the desktop |
| 3 | * (apps/desktop/src-tauri/src/links.rs): `g1t://acme/-/chat/dm/chn_1` is |
| 4 | * the page `/acme/-/chat/dm/chn_1`, as `https://g1t.sh/acme/-/chat/dm/chn_1` |
| 5 | * is. The app has screens for some of the site's pages; `routeFor` says |
| 6 | * which screen shows a site path, and the rest open on the site. |
| 7 | * |
| 8 | * Kept free of React Native imports so `npm test` runs it under Node. |
| 9 | */ |
| 10 | |
| 11 | /** Where the app's OAuth sign-in comes back to. */ |
| 12 | export const OAUTH_CALLBACK_PATH = "/oauth/callback"; |
| 13 | |
| 14 | /** |
| 15 | * Pages a workspace no longer has: Agents (with the skill library), |
| 16 | * Customize and the Marketplace. An old link to one opens Home. |
| 17 | */ |
| 18 | const RETIRED = new Set(["agents", "customize", "marketplace"]); |
| 19 | |
| 20 | /** Path segments a workspace or account can never be named (the site's own pages). */ |
| 21 | const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]); |
| 22 | |
| 23 | /** The filters a link to Work's list may carry, as the site's: `/acme/-/work?repo=web&label=bug`. */ |
| 24 | export const WORK_FILTERS = ["state", "label", "milestone", "assignee", "repo", "role"] as const; |
| 25 | |
| 26 | /** The filters a link to Work's sessions may carry: `/acme/-/work/sessions?state=live&repo=web`. */ |
| 27 | export const SESSION_FILTERS = ["state", "person", "repo", "item"] as const; |
| 28 | |
| 29 | /** |
| 30 | * The app's screen for a site path, or null when the app has none and the |
| 31 | * page should open on the site. |
| 32 | */ |
| 33 | export function routeFor(sitePath: string): string | null { |
| 34 | const [pathname, query = ""] = sitePath.split("?", 2); |
| 35 | const parts = pathname.split("/").filter(Boolean).map(decodeURIComponentSafe); |
| 36 | const keep = query ? `?${query}` : ""; |
| 37 | if (parts.length === 0) return "/"; |
| 38 | if (`/${parts.join("/")}` === OAUTH_CALLBACK_PATH) return OAUTH_CALLBACK_PATH + keep; |
| 39 | const [owner, second, third, fourth, fifth] = parts; |
| 40 | // `g1t://connect?server=<site>`: point the app at a g1t, as a self-hosted one's download page offers. |
| 41 | if (owner === "connect" && !second && /(^|&)server=/.test(query)) return `/connect?${query}`; |
| 42 | if (NOT_OWNERS.has(owner.toLowerCase())) return null; |
| 43 | const workspace = `workspace=${encodeURIComponent(owner)}`; |
| 44 | |
| 45 | // A workspace's own pages: /<workspace>/-/<page>. |
| 46 | if (second === "-") { |
| 47 | if (third === "chat") { |
| 48 | if (!fourth) return `/chat?${workspace}`; |
| 49 | if (fourth === "dm" && fifth) return `/chat/${encodeURIComponent(fifth)}?${workspace}`; |
| 50 | if (fourth !== "browse" && fourth !== "dm" && !fifth) return `/chat/${encodeURIComponent(fourth)}?${workspace}`; |
| 51 | return null; |
| 52 | } |
| 53 | if (third === "work") { |
| 54 | if (!fourth) return `/work?${workspace}${filtersOf(query, WORK_FILTERS)}`; |
| 55 | if (fourth === "new" && !fifth) return `/work/new?${workspace}`; |
| 56 | if (fourth === "board" && !fifth) return `/work?${workspace}&view=board`; |
| 57 | // Sessions are Work's: its Sessions view, and one session's screen. |
| 58 | if (fourth === "sessions") { |
| 59 | if (!fifth) return `/work?${workspace}&view=sessions${filtersOf(query, SESSION_FILTERS)}`; |
| 60 | if (parts.length === 5) return `/work/sessions/${encodeURIComponent(fifth)}?${workspace}`; |
| 61 | return null; |
| 62 | } |
| 63 | if (isNumber(fourth) && !fifth) return `/work/${fourth}?${workspace}`; |
| 64 | // Labels and milestones stay on the site. |
| 65 | return null; |
| 66 | } |
| 67 | if (third === "overview" && !fourth) return `/code?${workspace}`; |
| 68 | if (third && RETIRED.has(third)) return `/home?${workspace}`; |
| 69 | return null; |
| 70 | } |
| 71 | |
| 72 | // A repository's pages: /<owner>/<repo>[/pull/<n>]. |
| 73 | if (!second) return null; |
| 74 | const repo = `/code/${encodeURIComponent(owner)}/${encodeURIComponent(second)}`; |
| 75 | if (!third) return repo; |
| 76 | if (third === "pull" && isNumber(fourth) && !fifth) return `${repo}/pull/${fourth}`; |
| 77 | // Issues became the workspace's work items. An old issue's number is |
| 78 | // read in its repository, where it may name a pull request (they shared |
| 79 | // numbers then): the ref screen asks g1t which. |
| 80 | if (third === "issues") { |
| 81 | const name = `repo=${encodeURIComponent(second)}`; |
| 82 | if (!fourth) return `/work?${workspace}&${name}`; |
| 83 | if (fourth === "new" && !fifth) return `/work/new?${workspace}&${name}`; |
| 84 | if (isNumber(fourth) && !fifth) return refRoute({ repo: `${owner}/${second}` }, Number(fourth)); |
| 85 | return null; |
| 86 | } |
| 87 | return null; |
| 88 | } |
| 89 | |
| 90 | /** Where `#N` is read: in a repository (`owner/name`), or in a workspace alone. */ |
| 91 | export type RefContext = { repo: string } | { workspace: string }; |
| 92 | |
| 93 | /** The screen that asks g1t what `#N` names in a context, and opens it (app/ref.tsx). */ |
| 94 | export function refRoute(context: RefContext, number: number): string { |
| 95 | const where = "repo" in context ? `repo=${encodeURIComponent(context.repo)}` : `workspace=${encodeURIComponent(context.workspace)}`; |
| 96 | return `/ref?${where}&number=${number}`; |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * The context a reference in text is read in (crates/contracts/src/work/refs.rs): |
| 101 | * `owner/name#N` in that repository, `workspace#N` in that workspace, and |
| 102 | * `#N` where it was written. |
| 103 | */ |
| 104 | export function refContext(written: string | null, here: RefContext): RefContext { |
| 105 | if (!written) return here; |
| 106 | return written.includes("/") ? { repo: written } : { workspace: written }; |
| 107 | } |
| 108 | |
| 109 | /** What `#N` names, as the API answers (`RefTarget`, in snake_case). */ |
| 110 | export type RefTarget = |
| 111 | | { kind: "work_item"; workspace: string; number: number; item_id?: string } |
| 112 | | { kind: "pull"; repo: string; number: number } |
| 113 | | { kind: "none" }; |
| 114 | |
| 115 | /** The app's screen for what a reference names, or null for nothing. */ |
| 116 | export function targetRoute(target: RefTarget): string | null { |
| 117 | if (target.kind === "work_item") return `/work/${target.number}?workspace=${encodeURIComponent(target.workspace)}`; |
| 118 | if (target.kind === "pull") { |
| 119 | const [owner, name] = target.repo.split("/", 2); |
| 120 | if (!owner || !name) return null; |
| 121 | return `/code/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pull/${target.number}`; |
| 122 | } |
| 123 | return null; |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * The site's page for a reference, for when the app cannot ask: the site |
| 128 | * reads it the same way and redirects. |
| 129 | */ |
| 130 | export function refSitePath(context: RefContext, number: number): string { |
| 131 | return "repo" in context ? `/${context.repo}/issues/${number}` : `/${context.workspace}/-/work/${number}`; |
| 132 | } |
| 133 | |
| 134 | /** A session in a list: what kind it is, its repository (`owner/name`) and pull request. */ |
| 135 | export type SessionTarget = { kind: "harness" | "hosted" | "agent" | "recorded"; id: string; repo: string; pull: number | null }; |
| 136 | |
| 137 | /** |
| 138 | * Where tapping a session goes. Someone's harness and hosted ones have a |
| 139 | * page of their own; g1t's agent and a recorded one are their pull |
| 140 | * request's: its screen in Code, or its session on the site for someone |
| 141 | * without Code. Null when there is nowhere to go. |
| 142 | */ |
| 143 | export function sessionRoute(session: SessionTarget, workspace: string, code: boolean): string | null { |
| 144 | if (session.kind === "harness" || session.kind === "hosted") { |
| 145 | return `/work/sessions/${encodeURIComponent(session.id)}?workspace=${encodeURIComponent(workspace)}`; |
| 146 | } |
| 147 | const [owner, name] = session.repo.split("/", 2); |
| 148 | if (!session.pull || !owner || !name) return null; |
| 149 | if (code) return `/code/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pull/${session.pull}`; |
| 150 | return `/open?path=${encodeURIComponent(`/${owner}/${name}/pull/${session.pull}/session`)}`; |
| 151 | } |
| 152 | |
| 153 | /** |
| 154 | * The site path a `g1t://` link names, or null for any other link. |
| 155 | * `g1t://acme/-/chat?x` is `/acme/-/chat?x`. |
| 156 | */ |
| 157 | export function sitePathOf(link: string): string | null { |
| 158 | const match = /^g1t:\/\/([^?#]*)(\?[^#]*)?/i.exec(link); |
| 159 | if (!match) return null; |
| 160 | const path = `/${match[1].replace(/^\/+/, "")}`; |
| 161 | return path + (match[2] ?? ""); |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * The site path any link that opens the app names: a `g1t://` one, or an |
| 166 | * `https` one to the site (Android App Links, iOS Universal Links), as |
| 167 | * `https://g1t.sh/acme/-/work/12` is `/acme/-/work/12`. Null for others. |
| 168 | */ |
| 169 | export function linkPathOf(link: string): string | null { |
| 170 | const own = sitePathOf(link); |
| 171 | if (own) return own; |
| 172 | const web = /^https?:\/\/[^/?#]+(\/[^?#]*)?(\?[^#]*)?/i.exec(link); |
| 173 | return web ? (web[1] || "/") + (web[2] ?? "") : null; |
| 174 | } |
| 175 | |
| 176 | function filtersOf(query: string, names: readonly string[]): string { |
| 177 | const params = new URLSearchParams(query); |
| 178 | return names |
| 179 | .filter((name) => params.get(name)) |
| 180 | .map((name) => `&${name}=${encodeURIComponent(params.get(name)!)}`) |
| 181 | .join(""); |
| 182 | } |
| 183 | |
| 184 | function isNumber(part: string | undefined): part is string { |
| 185 | return Boolean(part && /^\d+$/.test(part)); |
| 186 | } |
| 187 | |
| 188 | function decodeURIComponentSafe(part: string): string { |
| 189 | try { |
| 190 | return decodeURIComponent(part); |
| 191 | } catch { |
| 192 | return part; |
| 193 | } |
| 194 | } |