1import type { WorkspaceSelections } from "../../../persistence/mobile-preferences";
2import {
3 SETTINGS_PAGE_TARGETS,
4 type SettingsPageTarget,
5} from "../../settings/components/settings-page-targets";
6import { settingsNavigationState } from "../../settings/settings-navigation";
7import type { WorkspaceTab } from "./workspace-tabs-state";
8
9type RouteLike = {
10 readonly name: string;
11 readonly key?: string;
12 readonly params?: object | undefined;
13};
14type ThreadSelection = NonNullable<WorkspaceSelections["threads"]>;
15type PullRequestSelection = NonNullable<WorkspaceSelections["pullRequests"]>;
16
17/** Opened when Settings has columns and none of its pages has been opened yet. */
18export const DEFAULT_SETTINGS_PAGE = "SettingsEnvironments" satisfies SettingsPageTarget;
19
20export function workspaceTabColumns(
21 platform: string,
22 tab: WorkspaceTab | "settings",
23 columns: boolean,
24) {
25 return columns && (platform === "ios" || (platform === "android" && tab === "settings"));
26}
27
28// Pages a Settings row opens, minus Project overview: it shows the project chosen in the
29// Settings scope, which resets whenever Settings closes.
30const REMEMBERED_SETTINGS_PAGES: ReadonlySet<string> = new Set(
31 SETTINGS_PAGE_TARGETS.filter((page) => page !== "SettingsProjectOverview"),
32);
33
34export function rememberedSettingsPage(selections: WorkspaceSelections): SettingsPageTarget {
35 if (selections.settings === "SettingsMicrophone") return "SettingsVoice";
36 return selections.settings && REMEMBERED_SETTINGS_PAGES.has(selections.settings)
37 ? (selections.settings as SettingsPageTarget)
38 : DEFAULT_SETTINGS_PAGE;
39}
40
41/** Whether a remembered chat or pull request can still be opened. */
42export interface WorkspaceSelectionCheck {
43 readonly thread: (selection: ThreadSelection) => boolean;
44 readonly pullRequest: (selection: PullRequestSelection) => boolean;
45}
46
47function stringParam(params: object | undefined, key: string) {
48 const value = (params as Record<string, unknown> | undefined)?.[key];
49 return typeof value === "string" ? value : undefined;
50}
51
52function pullRequestSelection(
53 params: object | undefined,
54): Omit<PullRequestSelection, "key"> | undefined {
55 const value = params as Record<string, unknown> | undefined;
56 if (
57 typeof value?.environmentId !== "string" ||
58 typeof value.projectId !== "string" ||
59 typeof value.repository !== "string" ||
60 typeof value.number !== "number"
61 ) {
62 return undefined;
63 }
64 return {
65 environmentId: value.environmentId,
66 projectId: value.projectId,
67 repository: value.repository,
68 number: value.number,
69 ...(typeof value.host === "string" ? { host: value.host } : {}),
70 };
71}
72
73function samePullRequest(
74 left: PullRequestSelection | undefined,
75 right: Omit<PullRequestSelection, "key">,
76) {
77 return (
78 left?.environmentId === right.environmentId &&
79 left.projectId === right.projectId &&
80 left.repository === right.repository &&
81 left.number === right.number &&
82 left.host === right.host
83 );
84}
85
86/**
87 * The page each tab shows in a root route chain: the newest chat, the newest pull request, and
88 * the Settings page directly above the Settings route. Returns `previous` when nothing changed.
89 */
90export function workspaceSelectionsIn(
91 routes: readonly RouteLike[],
92 previous: WorkspaceSelections,
93): WorkspaceSelections {
94 let next = previous;
95 const thread = routes.findLast((route) => route.name === "Thread");
96 const environmentId = stringParam(thread?.params, "environmentId");
97 const threadId = stringParam(thread?.params, "threadId");
98 if (
99 environmentId &&
100 threadId &&
101 (next.threads?.environmentId !== environmentId ||
102 next.threads.threadId !== threadId ||
103 next.threads.key !== thread?.key ||
104 next.threadsDraft)
105 ) {
106 const { threadsDraft: _, ...rest } = next;
107 next = { ...rest, threads: { environmentId, threadId, key: thread?.key } };
108 } else if (
109 thread?.key !== undefined &&
110 !threadId &&
111 (next.threadsDraft?.key !== thread.key || next.threadsDraft.params !== thread.params)
112 ) {
113 next = { ...next, threadsDraft: { key: thread.key, params: thread.params } };
114 }
115 const pullRequestRoute = routes.findLast((route) => route.name === "PullRequest");
116 const pullRequest = pullRequestSelection(pullRequestRoute?.params);
117 if (
118 pullRequest &&
119 (!samePullRequest(next.pullRequests, pullRequest) ||
120 next.pullRequests?.key !== pullRequestRoute?.key)
121 ) {
122 next = { ...next, pullRequests: { ...pullRequest, key: pullRequestRoute?.key } };
123 }
124 const list = routes.findIndex((route) => route.name === "Settings");
125 const page = list < 0 ? undefined : routes[list + 1];
126 if (
127 page &&
128 REMEMBERED_SETTINGS_PAGES.has(page.name) &&
129 (page.name !== next.settings || page.key !== next.settingsKey)
130 ) {
131 next = { ...next, settings: page.name, settingsKey: page.key };
132 }
133 return next;
134}
135
136function identityOf({ key: _key, ...identity }: PullRequestSelection) {
137 return identity;
138}
139
140let routeKeys = 0;
141
142/** New routes get their key here, so the caller knows which route it is about to show. */
143function keyed<Route extends RouteLike>(route: Route): Route & { readonly key: string } {
144 if (route.key !== undefined) return route as Route & { readonly key: string };
145 routeKeys += 1;
146 return { ...route, key: `${route.name}-workspace-${routeKeys}` };
147}
148
149function detailRoute(
150 state: { readonly routes: readonly RouteLike[] },
151 tab: "threads" | "pullRequests",
152 selections: WorkspaceSelections,
153 check: WorkspaceSelectionCheck,
154 seedPullRequest?: (identity: Omit<PullRequestSelection, "key">) => object,
155): RouteLike | undefined {
156 if (tab === "threads") {
157 const draft = selections.threadsDraft;
158 if (draft) {
159 return (
160 state.routes.find((route) => route.key === draft.key) ?? {
161 key: draft.key,
162 name: "Thread",
163 params: draft.params as object | undefined,
164 }
165 );
166 }
167 const thread = selections.threads;
168 if (!thread || !check.thread(thread)) return undefined;
169 return (
170 state.routes.find(
171 (route) =>
172 route.name === "Thread" &&
173 stringParam(route.params, "environmentId") === thread.environmentId &&
174 stringParam(route.params, "threadId") === thread.threadId,
175 ) ?? {
176 key: thread.key,
177 name: "Thread",
178 params: { environmentId: thread.environmentId, threadId: thread.threadId },
179 }
180 );
181 }
182 const pullRequest = selections.pullRequests;
183 if (!pullRequest || !check.pullRequest(pullRequest)) return undefined;
184 return (
185 state.routes.find(
186 (route) =>
187 route.name === "PullRequest" &&
188 samePullRequest(pullRequestSelection(route.params), pullRequest),
189 ) ?? {
190 key: pullRequest.key,
191 name: "PullRequest",
192 params: seedPullRequest?.(identityOf(pullRequest)) ?? identityOf(pullRequest),
193 }
194 );
195}
196
197/**
198 * The root stack for opening a workspace tab. With columns the detail column must show
199 * something, so the tab reopens the page it last showed (Settings falls back to its default);
200 * without columns the tab opens on its list. Routes already on screen keep their keys, and
201 * `overlays` (open sheets) stay on top.
202 */
203export function workspaceTabNavigationState<
204 State extends { readonly routes: readonly RouteLike[] },
205>(
206 state: State,
207 tab: WorkspaceTab | "settings",
208 selections: WorkspaceSelections,
209 options: {
210 readonly columns: boolean;
211 readonly check: WorkspaceSelectionCheck;
212 readonly overlays?: readonly RouteLike[];
213 readonly seedPullRequest?: (identity: Omit<PullRequestSelection, "key">) => object;
214 },
215) {
216 const overlays = options.overlays ?? [];
217 if (tab === "settings") {
218 const page = options.columns ? rememberedSettingsPage(selections) : undefined;
219 const settings = settingsNavigationState(
220 state,
221 page,
222 undefined,
223 page === selections.settings ? selections.settingsKey : undefined,
224 );
225 const routes = [...settings.routes.map(keyed), ...overlays];
226 return { ...settings, index: routes.length - 1, routes };
227 }
228 const home = state.routes.find((route) => route.name === "Home") ?? { name: "Home" };
229 const detail =
230 options.columns && tab !== "usage"
231 ? detailRoute(state, tab, selections, options.check, options.seedPullRequest)
232 : undefined;
233 const routes = [...[home, ...(detail ? [detail] : [])].map(keyed), ...overlays];
234 return { ...state, index: routes.length - 1, routes };
235}