Skip to content
Source
Sign inSign up

new-task-project-selection.ts

177 lines6.8 KB

1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
3import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
4import { repositoryGroupingKeyOf, type EnvironmentId, type ServerConfig } from "@t3tools/contracts";
5​
6import { scopedProjectKey } from "../../lib/scopedEntities";
7import type { HomeProjectScope } from "../home/homeThreadList";
8​
9type DraftProjectSelectionResolution =
10 | { readonly kind: "preserve" }
11 | { readonly kind: "select"; readonly project: EnvironmentProject }
12 | { readonly kind: "pick" }
13 | { readonly kind: "dismiss" };
14​
15/**
16 * The copy of a project a new thread starts in: the preferred environment's, then the
17 * representative. A connected copy wins over a disconnected one.
18 */
19export function getProjectScopeSelectionTarget(
20 scope: HomeProjectScope,
21 preferredEnvironmentId: EnvironmentId | null,
22 connectedEnvironmentIds: ReadonlySet<EnvironmentId>,
23): EnvironmentProject {
24 const rank = (project: EnvironmentProject) =>
25 project.environmentId === preferredEnvironmentId ? 0 : project === scope.representative ? 1 : 2;
26 const ordered = [...scope.projects].sort((left, right) => rank(left) - rank(right));
27 return (
28 ordered.find((project) => connectedEnvironmentIds.has(project.environmentId)) ??
29 ordered[0] ??
30 scope.representative
31 );
33​
34export function filterProjectScopes(
35 scopes: ReadonlyArray<HomeProjectScope>,
36 searchText: string,
37): ReadonlyArray<HomeProjectScope> {
38 const query = searchText.trim().toLowerCase();
39 if (!query) return scopes;
40 return scopes.filter(
41 (scope) =>
42 scope.title.toLowerCase().includes(query) ||
43 scope.projects.some(
44 (project) =>
45 project.title.toLowerCase().includes(query) ||
46 project.workspaceRoot.toLowerCase().includes(query),
47 ),
48 );
50​
51// Cached configuration reserves the row; connection readiness only gates activation.
52export function resolveNewTaskScratchOption(input: {
53 readonly environments: ReadonlyArray<{
54 readonly environmentId: EnvironmentId;
55 readonly connectionState: EnvironmentConnectionPhase;
56 }>;
57 readonly serverConfigs: ReadonlyMap<EnvironmentId, Pick<ServerConfig, "scratchWorkspaceRoot">>;
58 readonly selectedEnvironmentId: EnvironmentId | null;
59}) {
60 const offering = input.environments.filter(
61 (environment) =>
62 (input.serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot ?? null) !== null,
63 );
64 const ready = offering.filter(
65 (environment) =>
66 availableScratchWorkspaceRoot(
67 environment.connectionState,
68 input.serverConfigs.get(environment.environmentId),
69 ) !== null,
70 );
71 const candidates = ready.length > 0 ? ready : offering;
72 const environment =
73 candidates.find((candidate) => candidate.environmentId === input.selectedEnvironmentId) ??
74 candidates[0];
75 return environment ? { environmentId: environment.environmentId, ready: ready.length > 0 } : null;
77​
78export function resolveEnvironmentProjectMatch(
79 projectsOnTarget: ReadonlyArray<EnvironmentProject>,
80 selectedProject: EnvironmentProject | null,
81): EnvironmentProject | null {
82 const repositoryKey = selectedProject?.repositoryIdentity
83 ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity)
84 : null;
85 // Use || null: placeholder workspaceRoot can be empty, which supplies no useful basename.
86 const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
87 // Use weaker signals only when identity is unknown; different known repositories never match by basename or title.
88 const isKnownMismatch = (project: EnvironmentProject) => {
89 const projectKey = project.repositoryIdentity
90 ? repositoryGroupingKeyOf(project.repositoryIdentity)
91 : null;
92 return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey;
93 };
94 return (
95 (repositoryKey !== null
96 ? projectsOnTarget.find(
97 (project) =>
98 (project.repositoryIdentity
99 ? repositoryGroupingKeyOf(project.repositoryIdentity)
100 : null) === repositoryKey,
101 )
102 : undefined) ??
103 (workspaceBasename !== null
104 ? projectsOnTarget.find(
105 (project) =>
106 !isKnownMismatch(project) &&
107 project.workspaceRoot.split("/").at(-1) === workspaceBasename,
108 )
109 : undefined) ??
110 (selectedProject !== null
111 ? projectsOnTarget.find(
112 (project) => !isKnownMismatch(project) && project.title === selectedProject.title,
113 )
114 : undefined) ??
115 projectsOnTarget[0] ??
116 null
117 );
119​
120export function resolveDraftProjectSelection(
121 selectedProjectKey: string | null,
122 projects: ReadonlyArray<EnvironmentProject>,
123 projectScopes: ReadonlyArray<HomeProjectScope>,
124 draft: {
125 readonly incomingShareId: string | undefined;
126 /** The draft's first project picker closed and the draft is showing again. */
127 readonly pickerClosed: boolean;
128 },
129): DraftProjectSelectionResolution {
130 const hasExplicitProjectSelection =
131 selectedProjectKey !== null &&
132 projects.some(
133 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
134 );
135 if (hasExplicitProjectSelection) {
136 return { kind: "preserve" };
137 }
138 // A share draft holds content the user sent here, so cancelling the picker must not drop it.
139 if (draft.pickerClosed && selectedProjectKey === null && draft.incomingShareId === undefined) {
140 return { kind: "dismiss" };
141 }
142​
143 const onlyScope =
144 draft.incomingShareId !== undefined && projectScopes.length === 1 ? projectScopes[0] : null;
145 return onlyScope ? { kind: "select", project: onlyScope.representative } : { kind: "pick" };
147​
148type ProjectRef = { readonly environmentId: string; readonly projectId: string };
149​
150export function newTaskDestination(options: {
151 readonly projects: ReadonlyArray<EnvironmentProject>;
152 readonly requestId: string;
153 readonly useLastProject: boolean | undefined;
154 readonly currentProject: ProjectRef | undefined;
155 readonly lastProject: ProjectRef | undefined;
156}) {
157 const find = (ref: ProjectRef | undefined) =>
158 ref &&
159 options.projects.find(
160 (project) => project.environmentId === ref.environmentId && project.id === ref.projectId,
161 );
162 // A remembered project that was archived since opens the picker; the current one still wins.
163 const last = find(options.lastProject);
164 const project =
165 options.useLastProject === false
166 ? undefined
167 : (find(options.currentProject) ?? (last?.archivedAt == null ? last : undefined));
168 return project
169 ? {
170 draft: {
171 requestId: options.requestId,
172 environmentId: String(project.environmentId),
173 projectId: String(project.id),
174 },
175 }
176 : { draft: { requestId: options.requestId } };