History

Refactor mobile remote search and review state

- Move composer path search and source-control discovery into shared managed state - Persist review async/loading state in atoms and add coverage - Update mobile/web composers and review highlighter initialization

Julius Marminge committed 6 months ago

Commit e793c071 parent ab95f1137 files · +1366 −545Browse files

Unified · Split

37 files
  1. .oxfmtrc.json +2 −0
  2. .oxlintrc.json +2 −0
  3. apps/mobile/src/features/review/ReviewHighlighterProvider.tsx +5 −91
  4. apps/mobile/src/features/review/reviewHighlighterState.test.ts +75 −0
  5. apps/mobile/src/features/review/reviewHighlighterState.ts +154 −0
  6. apps/mobile/src/features/review/reviewModel.ts +2 −1
  7. apps/mobile/src/features/review/reviewState.test.ts +32 −0
  8. apps/mobile/src/features/review/reviewState.ts +73 −0
  9. apps/mobile/src/features/review/shikiReviewHighlighter.ts +1 −1
  10. apps/mobile/src/features/review/useReviewSections.ts +37 −18
  11. apps/mobile/src/features/threads/ThreadComposer.tsx +13 −64
  12. apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +1 −5
  13. apps/mobile/src/state/source-control-discovery-manager.ts +0 −46
  14. apps/mobile/src/state/use-composer-path-search.ts +43 −0
  15. apps/mobile/src/state/use-git-branches.ts +17 −2
  16. apps/mobile/src/state/use-remote-environment-registry.ts +1 −1
  17. apps/mobile/src/state/use-source-control-discovery.ts +51 −23
  18. apps/mobile/src/state/use-thread-detail.ts +1 −1
  19. apps/web/src/components/chat/ChatComposer.tsx +9 −28
  20. apps/web/src/lib/composerPathSearchState.ts +48 −0
  21. apps/web/src/lib/sourceControlDiscoveryState.ts +1 −1
  22. bun.lock +3 −0
  23. package.json +4 −1
  24. packages/client-runtime/src/composerPathSearchState.test.ts +111 −0
  25. packages/client-runtime/src/composerPathSearchState.ts +288 −0
  26. packages/client-runtime/src/gitBranchState.test.ts +33 −0
  27. packages/client-runtime/src/gitBranchState.ts +81 −0
  28. packages/client-runtime/src/index.ts +1 −0
  29. packages/client-runtime/src/sourceControlDiscoveryState.test.ts +102 −0
  30. packages/client-runtime/src/sourceControlDiscoveryState.ts +118 −8
  31. patches/@pierre%2Fdiffs@1.1.20.patch +23 −0
  32. patches/expo-dev-launcher@55.0.28.patch +15 −0
  33. patches/react-native-nitro-modules@0.35.4.patch +16 −0
  34. scripts/mobile-native-static-check.ts +3 −13
  35. scripts/patch-expo-dev-launcher.mjs +0 −63
  36. scripts/patch-pierre-diffs-utils.mjs +0 −105
  37. scripts/patch-react-native-nitro-modules.mjs +0 −73
.oxfmtrc.json+2−0
@@ -9,6 +9,8 @@ · 8 unmodified lines
9 "bun.lock",9 "bun.lock",
10 "*.tsbuildinfo",10 "*.tsbuildinfo",
11 "**/routeTree.gen.ts",11 "**/routeTree.gen.ts",
12 "apps/mobile/android/**",
13 "apps/mobile/ios/**",
12 "apps/web/public/mockServiceWorker.js",14 "apps/web/public/mockServiceWorker.js",
13 "apps/web/src/lib/vendor/qrcodegen.ts",15 "apps/web/src/lib/vendor/qrcodegen.ts",
14 "apps/mobile/uniwind-types.d.ts",16 "apps/mobile/uniwind-types.d.ts",
.oxlintrc.json+2−0
@@ -7,6 +7,8 @@ · 6 unmodified lines
7 "bun.lock",7 "bun.lock",
8 "*.tsbuildinfo",8 "*.tsbuildinfo",
9 "**/routeTree.gen.ts",9 "**/routeTree.gen.ts",
10 "apps/mobile/android/**",
11 "apps/mobile/ios/**",
10 "apps/mobile/uniwind-types.d.ts"12 "apps/mobile/uniwind-types.d.ts"
11 ],13 ],
12 "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"],14 "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"],
apps/mobile/src/features/review/ReviewHighlighterProvider.tsx+5−91
@@ -1,101 +1,15 @@
1import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react";1import { createContext, type ReactNode, useContext, useMemo } from "react";
2 2
3import {3import { type ReviewHighlighterState, useReviewHighlighterState } from "./reviewHighlighterState";
4 getActiveReviewHighlighterEngine,
5 prepareReviewHighlighter,
6 prepareReviewHighlighterLanguages,
7 type ReviewHighlighterEngine,
8} from "./shikiReviewHighlighter";
9 4
10type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error";5const ReviewHighlighterContext = createContext<ReviewHighlighterState>({
11
12interface ReviewHighlighterContextValue {
13 readonly engine: ReviewHighlighterEngine | null;
14 readonly error: string | null;
15 readonly status: ReviewHighlighterStatus;
16}
17
18const ReviewHighlighterContext = createContext<ReviewHighlighterContextValue>({
19 engine: null,6 engine: null,
20 error: null,7 error: null,
21 status: "idle",8 status: "idle",
22});9});
23 10
24const REVIEW_INITIAL_LANGUAGES = [
25 "typescript",
26 "tsx",
27 "javascript",
28 "jsx",
29 "json",
30 "yaml",
31 "bash",
32] as const;
33
34function isReviewHighlighterProviderDebugLoggingEnabled(): boolean {
35 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
36}
37
38function logReviewHighlighterProviderDiagnostic(
39 message: string,
40 details?: Record<string, unknown>,
41): void {
42 if (!isReviewHighlighterProviderDebugLoggingEnabled()) {
43 return;
44 }
45
46 if (details) {
47 console.log(`[review-highlighter-provider] ${message}`, details);
48 return;
49 }
50
51 console.log(`[review-highlighter-provider] ${message}`);
52}
53
54export function ReviewHighlighterProvider(props: { readonly children: ReactNode }) {11export function ReviewHighlighterProvider(props: { readonly children: ReactNode }) {
55 const [value, setValue] = useState<ReviewHighlighterContextValue>({12 const value = useReviewHighlighterState();
56 engine: null,
57 error: null,
58 status: "idle",
59 });
60
61 useEffect(() => {
62 let cancelled = false;
63
64 setValue({ engine: null, error: null, status: "initializing" });
65
66 void (async () => {
67 const startedAt = performance.now();
68 try {
69 await prepareReviewHighlighter();
70 await prepareReviewHighlighterLanguages(REVIEW_INITIAL_LANGUAGES);
71 const engine = await getActiveReviewHighlighterEngine();
72
73 if (cancelled) {
74 return;
75 }
76
77 const durationMs = Math.round(performance.now() - startedAt);
78 logReviewHighlighterProviderDiagnostic("initialized", {
79 durationMs,
80 engine,
81 });
82 setValue({ engine, error: null, status: "ready" });
83 } catch (error) {
84 if (cancelled) {
85 return;
86 }
87
88 const message = error instanceof Error ? error.message : String(error);
89 logReviewHighlighterProviderDiagnostic("initialization failed", { error: message });
90 setValue({ engine: null, error: message, status: "error" });
91 }
92 })();
93
94 return () => {
95 cancelled = true;
96 };
97 }, []);
98
99 const contextValue = useMemo(() => value, [value]);13 const contextValue = useMemo(() => value, [value]);
100 14
101 return (15 return (
@@ -105,6 +19,6 @@ export function ReviewHighlighterProvider(props: { readonly children: ReactNode · 3 unmodified lines
105 );19 );
106}20}
107 21
108export function useReviewHighlighterStatus(): ReviewHighlighterContextValue {22export function useReviewHighlighterStatus(): ReviewHighlighterState {
109 return useContext(ReviewHighlighterContext);23 return useContext(ReviewHighlighterContext);
110}24}
apps/mobile/src/features/review/reviewHighlighterState.test.ts+75−0
@@ -0,0 +1,75 @@
1import { assert, beforeEach, it } from "vitest";
2import { AtomRegistry } from "effect/unstable/reactivity";
3
4import {
5 createReviewHighlighterManager,
6 IDLE_REVIEW_HIGHLIGHTER_STATE,
7} from "./reviewHighlighterState";
8
9let registry = AtomRegistry.make();
10
11beforeEach(() => {
12 registry.dispose();
13 registry = AtomRegistry.make();
14});
15
16function flushAsyncWork(): Promise<void> {
17 return Promise.resolve().then(() => undefined);
18}
19
20it("initializes review highlighter state once", async () => {
21 let prepareCalls = 0;
22 let languageCalls = 0;
23 let engineCalls = 0;
24 const manager = createReviewHighlighterManager({
25 getRegistry: () => registry,
26 loader: {
27 prepare: async () => {
28 prepareCalls += 1;
29 },
30 prepareLanguages: async () => {
31 languageCalls += 1;
32 },
33 getEngine: async () => {
34 engineCalls += 1;
35 return "javascript";
36 },
37 },
38 });
39
40 assert.deepStrictEqual(manager.getSnapshot(), IDLE_REVIEW_HIGHLIGHTER_STATE);
41
42 await Promise.all([manager.initialize(), manager.initialize()]);
43 await manager.initialize();
44
45 assert.strictEqual(prepareCalls, 1);
46 assert.strictEqual(languageCalls, 1);
47 assert.strictEqual(engineCalls, 1);
48 assert.deepStrictEqual(manager.getSnapshot(), {
49 engine: "javascript",
50 error: null,
51 status: "ready",
52 });
53});
54
55it("stores initialization failures in atom state", async () => {
56 const manager = createReviewHighlighterManager({
57 getRegistry: () => registry,
58 loader: {
59 prepare: async () => {
60 throw new Error("load failed");
61 },
62 prepareLanguages: async () => undefined,
63 getEngine: async () => "javascript",
64 },
65 });
66
67 void manager.initialize();
68 await flushAsyncWork();
69
70 assert.deepStrictEqual(manager.getSnapshot(), {
71 engine: null,
72 error: "load failed",
73 status: "error",
74 });
75});
apps/mobile/src/features/review/reviewHighlighterState.ts+154−0
@@ -0,0 +1,154 @@
1import { useAtomValue } from "@effect/atom-react";
2import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3import { useEffect } from "react";
4
5import { appAtomRegistry } from "../../state/atom-registry";
6import {
7 getActiveReviewHighlighterEngine,
8 prepareReviewHighlighter,
9 prepareReviewHighlighterLanguages,
10 type ReviewHighlighterEngine,
11} from "./shikiReviewHighlighter";
12
13export type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error";
14
15export interface ReviewHighlighterState {
16 readonly engine: ReviewHighlighterEngine | null;
17 readonly error: string | null;
18 readonly status: ReviewHighlighterStatus;
19}
20
21export interface ReviewHighlighterLoader {
22 readonly prepare: () => Promise<void>;
23 readonly prepareLanguages: (languages: ReadonlyArray<string>) => Promise<void>;
24 readonly getEngine: () => Promise<ReviewHighlighterEngine>;
25}
26
27const REVIEW_INITIAL_LANGUAGES = [
28 "typescript",
29 "tsx",
30 "javascript",
31 "jsx",
32 "json",
33 "yaml",
34 "bash",
35] as const;
36
37export const IDLE_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({
38 engine: null,
39 error: null,
40 status: "idle",
41});
42
43const INITIALIZING_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({
44 engine: null,
45 error: null,
46 status: "initializing",
47});
48
49export const reviewHighlighterStateAtom = Atom.make(IDLE_REVIEW_HIGHLIGHTER_STATE).pipe(
50 Atom.keepAlive,
51 Atom.withLabel("mobile:review-highlighter"),
52);
53
54function isReviewHighlighterProviderDebugLoggingEnabled(): boolean {
55 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
56}
57
58function logReviewHighlighterProviderDiagnostic(
59 message: string,
60 details?: Record<string, unknown>,
61): void {
62 if (!isReviewHighlighterProviderDebugLoggingEnabled()) {
63 return;
64 }
65
66 if (details) {
67 console.log(`[review-highlighter-provider] ${message}`, details);
68 return;
69 }
70
71 console.log(`[review-highlighter-provider] ${message}`);
72}
73
74export function createReviewHighlighterManager(config: {
75 readonly getRegistry: () => AtomRegistry.AtomRegistry;
76 readonly loader: ReviewHighlighterLoader;
77 readonly languages?: ReadonlyArray<string>;
78}) {
79 let started = false;
80 let inFlight: Promise<void> | null = null;
81
82 function getSnapshot(): ReviewHighlighterState {
83 return config.getRegistry().get(reviewHighlighterStateAtom);
84 }
85
86 function setState(state: ReviewHighlighterState): void {
87 config.getRegistry().set(reviewHighlighterStateAtom, state);
88 }
89
90 function initialize(): Promise<void> {
91 if (inFlight) {
92 return inFlight;
93 }
94
95 if (started && getSnapshot().status === "ready") {
96 return Promise.resolve();
97 }
98
99 started = true;
100 setState(INITIALIZING_REVIEW_HIGHLIGHTER_STATE);
101
102 inFlight = (async () => {
103 const startedAt = performance.now();
104 try {
105 await config.loader.prepare();
106 await config.loader.prepareLanguages(config.languages ?? REVIEW_INITIAL_LANGUAGES);
107 const engine = await config.loader.getEngine();
108 const durationMs = Math.round(performance.now() - startedAt);
109 logReviewHighlighterProviderDiagnostic("initialized", {
110 durationMs,
111 engine,
112 });
113 setState({ engine, error: null, status: "ready" });
114 } catch (error) {
115 const message = error instanceof Error ? error.message : String(error);
116 logReviewHighlighterProviderDiagnostic("initialization failed", { error: message });
117 setState({ engine: null, error: message, status: "error" });
118 } finally {
119 inFlight = null;
120 }
121 })();
122
123 return inFlight;
124 }
125
126 function reset(): void {
127 started = false;
128 inFlight = null;
129 setState(IDLE_REVIEW_HIGHLIGHTER_STATE);
130 }
131
132 return {
133 getSnapshot,
134 initialize,
135 reset,
136 };
137}
138
139const reviewHighlighterManager = createReviewHighlighterManager({
140 getRegistry: () => appAtomRegistry,
141 loader: {
142 prepare: prepareReviewHighlighter,
143 prepareLanguages: prepareReviewHighlighterLanguages,
144 getEngine: getActiveReviewHighlighterEngine,
145 },
146});
147
148export function useReviewHighlighterState(): ReviewHighlighterState {
149 useEffect(() => {
150 void reviewHighlighterManager.initialize();
151 }, []);
152
153 return useAtomValue(reviewHighlighterStateAtom);
154}
apps/mobile/src/features/review/reviewModel.ts+2−1
@@ -1,4 +1,5 @@
1import { parsePatchFiles, type ChangeTypes, type FileDiffMetadata } from "@pierre/diffs/utils";1import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
2import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";3import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";
3import * as Arr from "effect/Array";4import * as Arr from "effect/Array";
4import * as Order from "effect/Order";5import * as Order from "effect/Order";
apps/mobile/src/features/review/reviewState.test.ts+32−0
@@ -0,0 +1,32 @@
1import { assert, it } from "vitest";
2
3import {
4 getReviewAsyncStateSnapshot,
5 setReviewAsyncError,
6 setReviewGitDiffsLoading,
7 setReviewTurnDiffLoading,
8} from "./reviewState";
9
10it("stores review async loading and error state in atoms", () => {
11 const threadKey = `env-local:thread-review-state-${Date.now()}`;
12
13 setReviewGitDiffsLoading(threadKey, true);
14 setReviewTurnDiffLoading(threadKey, "turn-1", true);
15 setReviewAsyncError(threadKey, "load failed");
16
17 assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), {
18 loadingGitDiffs: true,
19 loadingTurnIds: { "turn-1": true },
20 error: "load failed",
21 });
22
23 setReviewGitDiffsLoading(threadKey, false);
24 setReviewTurnDiffLoading(threadKey, "turn-1", false);
25 setReviewAsyncError(threadKey, null);
26
27 assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), {
28 loadingGitDiffs: false,
29 loadingTurnIds: {},
30 error: null,
31 });
32});
apps/mobile/src/features/review/reviewState.ts+73−0
@@ -9,6 +9,12 @@ import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; · 8 unmodified lines
9 9
10const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<ReviewDiffPreviewSource>>([]);10const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<ReviewDiffPreviewSource>>([]);
11const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({});11const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({});
12const EMPTY_REVIEW_LOADING_TURN_IDS = Object.freeze<Readonly<Record<string, boolean>>>({});
13const EMPTY_REVIEW_ASYNC_STATE = Object.freeze<ReviewAsyncState>({
14 loadingGitDiffs: false,
15 loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS,
16 error: null,
17});
12const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze<18const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze<
13 Readonly<Record<string, ReadonlyArray<string> | undefined>>19 Readonly<Record<string, ReadonlyArray<string> | undefined>>
14>({});20>({});
@@ -24,6 +30,10 @@ const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make<string | null>(null).pip · 9 unmodified lines
24 Atom.keepAlive,30 Atom.keepAlive,
25 Atom.withLabel("mobile:review:selected-section-id:null"),31 Atom.withLabel("mobile:review:selected-section-id:null"),
26);32);
33const EMPTY_REVIEW_ASYNC_STATE_ATOM = Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe(
34 Atom.keepAlive,
35 Atom.withLabel("mobile:review:async-state:null"),
36);
27const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(37const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
28 Atom.keepAlive,38 Atom.keepAlive,
29 Atom.withLabel("mobile:review:section-file-ids:null"),39 Atom.withLabel("mobile:review:section-file-ids:null"),
@@ -50,6 +60,13 @@ const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) = · 20 unmodified lines
50 ),60 ),
51);61);
52 62
63const reviewAsyncStateByThreadKeyAtom = Atom.family((threadKey: string) =>
64 Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe(
65 Atom.keepAlive,
66 Atom.withLabel(`mobile:review:async-state:${threadKey}`),
67 ),
68);
69
53const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>70const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) =>
54 Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(71 Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe(
55 Atom.keepAlive,72 Atom.keepAlive,
@@ -83,6 +100,7 @@ export interface ReviewCacheForThread { · 27 unmodified lines
83 readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>;100 readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>;
84 readonly turnDiffById: Readonly<Record<string, string>>;101 readonly turnDiffById: Readonly<Record<string, string>>;
85 readonly selectedSectionId: string | null;102 readonly selectedSectionId: string | null;
103 readonly asyncState: ReviewAsyncState;
86 readonly expandedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>;104 readonly expandedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>;
87 readonly revealedLargeFileIdsBySection: Readonly<105 readonly revealedLargeFileIdsBySection: Readonly<
88 Record<string, ReadonlyArray<string> | undefined>106 Record<string, ReadonlyArray<string> | undefined>
@@ -90,6 +108,12 @@ export interface ReviewCacheForThread { · 1 unmodified line
90 readonly viewedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>;108 readonly viewedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>;
91}109}
92 110
111export interface ReviewAsyncState {
112 readonly loadingGitDiffs: boolean;
113 readonly loadingTurnIds: Readonly<Record<string, boolean>>;
114 readonly error: string | null;
115}
116
93function buildThreadKey(input: {117function buildThreadKey(input: {
94 readonly environmentId?: EnvironmentId;118 readonly environmentId?: EnvironmentId;
95 readonly threadId?: ThreadId;119 readonly threadId?: ThreadId;
@@ -119,6 +143,9 @@ export function useReviewCacheForThread(input: { · 23 unmodified lines
119 ? reviewSelectedSectionIdByThreadKeyAtom(threadKey)143 ? reviewSelectedSectionIdByThreadKeyAtom(threadKey)
120 : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM,144 : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM,
121 );145 );
146 const asyncState = useAtomValue(
147 threadKey ? reviewAsyncStateByThreadKeyAtom(threadKey) : EMPTY_REVIEW_ASYNC_STATE_ATOM,
148 );
122 const expandedFileIdsBySection = useAtomValue(149 const expandedFileIdsBySection = useAtomValue(
123 threadKey150 threadKey
124 ? reviewExpandedFileIdsByThreadKeyAtom(threadKey)151 ? reviewExpandedFileIdsByThreadKeyAtom(threadKey)
@@ -138,6 +165,7 @@ export function useReviewCacheForThread(input: { · 13 unmodified lines
138 gitSections,165 gitSections,
139 turnDiffById,166 turnDiffById,
140 selectedSectionId,167 selectedSectionId,
168 asyncState,
141 expandedFileIdsBySection,169 expandedFileIdsBySection,
142 revealedLargeFileIdsBySection,170 revealedLargeFileIdsBySection,
143 viewedFileIdsBySection,171 viewedFileIdsBySection,
@@ -164,6 +192,51 @@ export function setReviewSelectedSectionId(threadKey: string, sectionId: string · 20 unmodified lines
164 appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId);192 appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId);
165}193}
166 194
195function updateReviewAsyncState(
196 threadKey: string,
197 update: (current: ReviewAsyncState) => ReviewAsyncState,
198): void {
199 const atom = reviewAsyncStateByThreadKeyAtom(threadKey);
200 appAtomRegistry.set(atom, update(appAtomRegistry.get(atom)));
201}
202
203export function setReviewGitDiffsLoading(threadKey: string, loadingGitDiffs: boolean): void {
204 updateReviewAsyncState(threadKey, (current) => ({
205 ...current,
206 loadingGitDiffs,
207 }));
208}
209
210export function setReviewTurnDiffLoading(
211 threadKey: string,
212 sectionId: string,
213 isLoading: boolean,
214): void {
215 updateReviewAsyncState(threadKey, (current) => {
216 const loadingTurnIds = { ...current.loadingTurnIds };
217 if (isLoading) {
218 loadingTurnIds[sectionId] = true;
219 } else {
220 delete loadingTurnIds[sectionId];
221 }
222 return {
223 ...current,
224 loadingTurnIds,
225 };
226 });
227}
228
229export function setReviewAsyncError(threadKey: string, error: string | null): void {
230 updateReviewAsyncState(threadKey, (current) => ({
231 ...current,
232 error,
233 }));
234}
235
236export function getReviewAsyncStateSnapshot(threadKey: string): ReviewAsyncState {
237 return appAtomRegistry.get(reviewAsyncStateByThreadKeyAtom(threadKey));
238}
239
167export function updateReviewExpandedFileIds(240export function updateReviewExpandedFileIds(
168 threadKey: string,241 threadKey: string,
169 sectionId: string,242 sectionId: string,
apps/mobile/src/features/review/shikiReviewHighlighter.ts+1−1
@@ -9,7 +9,7 @@ import typescriptLanguage from "@shikijs/langs/typescript"; · 8 unmodified lines
9import yamlLanguage from "@shikijs/langs/yaml";9import yamlLanguage from "@shikijs/langs/yaml";
10import githubDarkDefault from "@shikijs/themes/github-dark-default";10import githubDarkDefault from "@shikijs/themes/github-dark-default";
11import githubLightDefault from "@shikijs/themes/github-light-default";11import githubLightDefault from "@shikijs/themes/github-light-default";
12import { getFiletypeFromFileName } from "@pierre/diffs/utils";12import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName";
13 13
14import {14import {
15 resolveReviewHighlighterEngine,15 resolveReviewHighlighterEngine,
apps/mobile/src/features/review/useReviewSections.ts+37−18
@@ -1,4 +1,4 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useCallback, useEffect, useMemo, useRef } from "react";
2 2
3import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";3import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts";
4 4
@@ -12,8 +12,11 @@ import { · 7 unmodified lines
12 getReviewSectionIdForCheckpoint,12 getReviewSectionIdForCheckpoint,
13} from "./reviewModel";13} from "./reviewModel";
14import {14import {
15 setReviewAsyncError,
16 setReviewGitDiffsLoading,
15 setReviewGitSections,17 setReviewGitSections,
16 setReviewSelectedSectionId,18 setReviewSelectedSectionId,
19 setReviewTurnDiffLoading,
17 setReviewTurnDiff,20 setReviewTurnDiff,
18 type ReviewCacheForThread,21 type ReviewCacheForThread,
19} from "./reviewState";22} from "./reviewState";
@@ -26,9 +29,7 @@ export function useReviewSections(input: { · 6 unmodified lines
26 const { environmentId, reviewCache, threadId } = input;29 const { environmentId, reviewCache, threadId } = input;
27 const selectedThread = useSelectedThreadDetail();30 const selectedThread = useSelectedThreadDetail();
28 const { selectedThreadCwd } = useSelectedThreadWorktree();31 const { selectedThreadCwd } = useSelectedThreadWorktree();
29 const [loadingTurnIds, setLoadingTurnIds] = useState<Record<string, boolean>>({});32 const { error, loadingGitDiffs, loadingTurnIds } = reviewCache.asyncState;
30 const [loadingGitDiffs, setLoadingGitDiffs] = useState(false);
31 const [error, setError] = useState<string | null>(null);
32 const turnDiffByIdRef = useRef(reviewCache.turnDiffById);33 const turnDiffByIdRef = useRef(reviewCache.turnDiffById);
33 34
34 useEffect(() => {35 useEffect(() => {
@@ -84,21 +85,32 @@ export function useReviewSections(input: { · 49 unmodified lines
84 85
85 const client = getEnvironmentClient(environmentId);86 const client = getEnvironmentClient(environmentId);
86 if (!client) {87 if (!client) {
87 setError("Remote connection is not ready.");88 if (reviewCache.threadKey) {
89 setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready.");
90 }
88 return;91 return;
89 }92 }
90 93
91 setLoadingGitDiffs(true);94 if (reviewCache.threadKey) {
92 setError(null);95 setReviewGitDiffsLoading(reviewCache.threadKey, true);
96 setReviewAsyncError(reviewCache.threadKey, null);
97 }
93 try {98 try {
94 const result = await client.review.getDiffPreview({ cwd: selectedThreadCwd });99 const result = await client.review.getDiffPreview({ cwd: selectedThreadCwd });
95 if (reviewCache.threadKey) {100 if (reviewCache.threadKey) {
96 setReviewGitSections(reviewCache.threadKey, result.sources);101 setReviewGitSections(reviewCache.threadKey, result.sources);
97 }102 }
98 } catch (cause) {103 } catch (cause) {
99 setError(cause instanceof Error ? cause.message : "Failed to load review diffs.");104 if (reviewCache.threadKey) {
105 setReviewAsyncError(
106 reviewCache.threadKey,
107 cause instanceof Error ? cause.message : "Failed to load review diffs.",
108 );
109 }
100 } finally {110 } finally {
101 setLoadingGitDiffs(false);111 if (reviewCache.threadKey) {
112 setReviewGitDiffsLoading(reviewCache.threadKey, false);
113 }
102 }114 }
103 }, [environmentId, reviewCache.threadKey, selectedThreadCwd]);115 }, [environmentId, reviewCache.threadKey, selectedThreadCwd]);
104 116
@@ -119,12 +131,16 @@ export function useReviewSections(input: { · 14 unmodified lines
119 131
120 const client = getEnvironmentClient(environmentId);132 const client = getEnvironmentClient(environmentId);
121 if (!client) {133 if (!client) {
122 setError("Remote connection is not ready.");134 if (reviewCache.threadKey) {
135 setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready.");
136 }
123 return;137 return;
124 }138 }
125 139
126 setLoadingTurnIds((current) => ({ ...current, [sectionId]: true }));140 if (reviewCache.threadKey) {
127 setError(null);141 setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true);
142 setReviewAsyncError(reviewCache.threadKey, null);
143 }
128 try {144 try {
129 const result = await client.orchestration.getTurnDiff({145 const result = await client.orchestration.getTurnDiff({
130 threadId,146 threadId,
@@ -135,13 +151,16 @@ export function useReviewSections(input: { · 4 unmodified lines
135 setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff);151 setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff);
136 }152 }
137 } catch (cause) {153 } catch (cause) {
138 setError(cause instanceof Error ? cause.message : "Failed to load turn diff.");154 if (reviewCache.threadKey) {
155 setReviewAsyncError(
156 reviewCache.threadKey,
157 cause instanceof Error ? cause.message : "Failed to load turn diff.",
158 );
159 }
139 } finally {160 } finally {
140 setLoadingTurnIds((current) => {161 if (reviewCache.threadKey) {
141 const next = { ...current };162 setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false);
142 delete next[sectionId];163 }
143 return next;
144 });
145 }164 }
146 },165 },
147 [environmentId, reviewCache.threadKey, threadId],166 [environmentId, reviewCache.threadKey, threadId],
apps/mobile/src/features/threads/ThreadComposer.tsx+13−64
@@ -42,7 +42,7 @@ import { · 41 unmodified lines
42 normalizeSearchQuery,42 normalizeSearchQuery,
43 scoreQueryMatch,43 scoreQueryMatch,
44} from "@t3tools/shared/searchRanking";44} from "@t3tools/shared/searchRanking";
45import { getEnvironmentClient } from "../../state/environment-session-registry";45import { useComposerPathSearch } from "../../state/use-composer-path-search";
46import { CLAUDE_AGENT_EFFORT_OPTIONS, type ClaudeAgentEffort } from "./claudeEffortOptions";46import { CLAUDE_AGENT_EFFORT_OPTIONS, type ClaudeAgentEffort } from "./claudeEffortOptions";
47import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";47import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
48 48
@@ -209,12 +209,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 160 unmodified lines
209 209
210 // ── Trigger detection ────────────────────────────────────210 // ── Trigger detection ────────────────────────────────────
211 const [cursorPosition, setCursorPosition] = useState(0);211 const [cursorPosition, setCursorPosition] = useState(0);
212 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(null);
213 const [pathSearchResults, setPathSearchResults] = useState<
214 ReadonlyArray<{ path: string; kind: "file" | "directory" }>
215 >([]);
216 const [pathSearchLoading, setPathSearchLoading] = useState(false);
217 const pathSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
218 212
219 const handleSelectionChange = useCallback(213 const handleSelectionChange = useCallback(
220 (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {214 (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
@@ -224,60 +218,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 3 unmodified lines
224 [],218 [],
225 );219 );
226 220
227 useEffect(() => {221 const composerTrigger = useMemo<ComposerTrigger | null>(
228 const trigger = detectComposerTrigger(props.draftMessage, cursorPosition);222 () => detectComposerTrigger(props.draftMessage, cursorPosition),
229 setComposerTrigger(trigger);223 [cursorPosition, props.draftMessage],
230 }, [props.draftMessage, cursorPosition]);224 );
231 225 const pathSearch = useComposerPathSearch({
232 // ── File search (debounced) ──────────────────────────────226 environmentId: props.environmentId,
233 useEffect(() => {227 cwd: composerTrigger?.kind === "path" ? props.projectCwd : null,
234 if (pathSearchTimerRef.current) {228 query: composerTrigger?.kind === "path" ? composerTrigger.query : null,
235 clearTimeout(pathSearchTimerRef.current);229 });
236 pathSearchTimerRef.current = null;
237 }
238
239 if (composerTrigger?.kind !== "path" || !composerTrigger.query || !props.projectCwd) {
240 setPathSearchResults([]);
241 setPathSearchLoading(false);
242 return;
243 }
244
245 setPathSearchLoading(true);
246 const query = composerTrigger.query;
247 const cwd = props.projectCwd;
248 const envId = props.environmentId;
249
250 pathSearchTimerRef.current = setTimeout(() => {
251 const client = getEnvironmentClient(envId);
252 if (!client) {
253 setPathSearchLoading(false);
254 return;
255 }
256
257 void client.projects
258 .searchEntries({ cwd, query, limit: 20 })
259 .then((result) => {
260 setPathSearchResults(
261 result.entries.map((entry) => ({
262 path: entry.path,
263 kind: entry.kind === "directory" ? ("directory" as const) : ("file" as const),
264 })),
265 );
266 })
267 .catch(() => {
268 setPathSearchResults([]);
269 })
270 .finally(() => {
271 setPathSearchLoading(false);
272 });
273 }, 200);
274
275 return () => {
276 if (pathSearchTimerRef.current) {
277 clearTimeout(pathSearchTimerRef.current);
278 }
279 };
280 }, [composerTrigger?.kind, composerTrigger?.query, props.projectCwd, props.environmentId]);
281 230
282 // ── Build menu items ─────────────────────────────────────231 // ── Build menu items ─────────────────────────────────────
283 const selectedProviderStatus = useMemo(() => {232 const selectedProviderStatus = useMemo(() => {
@@ -416,7 +365,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 132 unmodified lines
416 }365 }
417 366
418 if (composerTrigger.kind === "path") {367 if (composerTrigger.kind === "path") {
419 return pathSearchResults.map((entry) => {368 return pathSearch.entries.map((entry) => {
420 const parts = entry.path.split("/");369 const parts = entry.path.split("/");
421 return {370 return {
422 id: `path:${entry.path}`,371 id: `path:${entry.path}`,
@@ -430,7 +379,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 7 unmodified lines
430 }379 }
431 380
432 return [];381 return [];
433 }, [composerTrigger, selectedProviderStatus, pathSearchResults]);382 }, [composerTrigger, pathSearch.entries, selectedProviderStatus]);
434 383
435 // ── Handle command selection ──────────────────────────────384 // ── Handle command selection ──────────────────────────────
436 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;385 const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
@@ -680,7 +629,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 243 unmodified lines
680 <ComposerCommandPopover629 <ComposerCommandPopover
681 items={composerMenuItems}630 items={composerMenuItems}
682 triggerKind={composerTrigger.kind}631 triggerKind={composerTrigger.kind}
683 isLoading={pathSearchLoading}632 isLoading={pathSearch.isPending}
684 onSelect={handleCommandSelect}633 onSelect={handleCommandSelect}
685 />634 />
686 </View>635 </View>
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+1−5
@@ -1,6 +1,6 @@
1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";1import { sanitizeFeatureBranchName } from "@t3tools/shared/git";
2import { useRouter } from "expo-router";2import { useRouter } from "expo-router";
3import { useEffect, useState } from "react";3import { useState } from "react";
4import { Pressable, ScrollView, View } from "react-native";4import { Pressable, ScrollView, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
6import { useThemeColor } from "../../../lib/useThemeColor";6import { useThemeColor } from "../../../lib/useThemeColor";
@@ -52,10 +52,6 @@ export function GitBranchesSheet() { · 45 unmodified lines
52 .map((branch) => branch.name),52 .map((branch) => branch.name),
53 );53 );
54 54
55 useEffect(() => {
56 void gitActions.refreshSelectedThreadBranches();
57 }, [gitActions]);
58
59 return (55 return (
60 <ScrollView56 <ScrollView
61 showsVerticalScrollIndicator={false}57 showsVerticalScrollIndicator={false}
apps/mobile/src/state/source-control-discovery-manager.ts+0−46
@@ -1,46 +0,0 @@
1import {
2 type SourceControlDiscoveryClient,
3 type SourceControlDiscoveryTarget,
4 createSourceControlDiscoveryManager,
5} from "@t3tools/client-runtime";
6import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts";
7
8import { appAtomRegistry } from "./atom-registry";
9import { getEnvironmentClient } from "./environment-session-registry";
10
11export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager<EnvironmentId>({
12 getRegistry: () => appAtomRegistry,
13 getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null,
14});
15
16export function sourceControlDiscoveryTargetForEnvironment(
17 environmentId: EnvironmentId | null,
18): SourceControlDiscoveryTarget<EnvironmentId> {
19 return { key: environmentId ?? null };
20}
21
22export function refreshSourceControlDiscoveryForEnvironment(
23 environmentId: EnvironmentId | null,
24 client?: SourceControlDiscoveryClient | null,
25): Promise<SourceControlDiscoveryResult | null> {
26 return sourceControlDiscoveryManager.refresh(
27 sourceControlDiscoveryTargetForEnvironment(environmentId),
28 client ?? undefined,
29 );
30}
31
32export function invalidateSourceControlDiscoveryForEnvironment(
33 environmentId: EnvironmentId | null,
34): void {
35 sourceControlDiscoveryManager.invalidate(
36 sourceControlDiscoveryTargetForEnvironment(environmentId),
37 );
38}
39
40export function resetSourceControlDiscoveryState(): void {
41 sourceControlDiscoveryManager.reset();
42}
43
44export function resetSourceControlDiscoveryStateForTests(): void {
45 resetSourceControlDiscoveryState();
46}
apps/mobile/src/state/use-composer-path-search.ts+43−0
@@ -0,0 +1,43 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type ComposerPathSearchState,
4 type ComposerPathSearchTarget,
5 EMPTY_COMPOSER_PATH_SEARCH_ATOM,
6 EMPTY_COMPOSER_PATH_SEARCH_STATE,
7 composerPathSearchStateAtom,
8 createComposerPathSearchManager,
9 getComposerPathSearchTargetKey,
10 normalizeComposerPathSearchQuery,
11} from "@t3tools/client-runtime";
12import { useEffect, useMemo } from "react";
13
14import { appAtomRegistry } from "./atom-registry";
15import {
16 getEnvironmentClient,
17 subscribeEnvironmentConnections,
18} from "./environment-session-registry";
19
20const composerPathSearchManager = createComposerPathSearchManager({
21 getRegistry: () => appAtomRegistry,
22 getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null,
23 subscribeClientChanges: subscribeEnvironmentConnections,
24});
25
26export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState {
27 const stableTarget = useMemo(
28 () => ({
29 environmentId: target.environmentId,
30 cwd: target.cwd,
31 query: normalizeComposerPathSearchQuery(target.query),
32 }),
33 [target.cwd, target.environmentId, target.query],
34 );
35 const targetKey = getComposerPathSearchTargetKey(stableTarget);
36
37 useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]);
38
39 const state = useAtomValue(
40 targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM,
41 );
42 return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state;
43}
apps/mobile/src/state/use-git-branches.ts+17−2
@@ -1,4 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useEffect, useMemo } from "react";
2import {3import {
3 type GitBranchState,4 type GitBranchState,
4 type GitBranchTarget,5 type GitBranchTarget,
@@ -10,7 +11,10 @@ import { · 5 unmodified lines
10} from "@t3tools/client-runtime";11} from "@t3tools/client-runtime";
11 12
12import { appAtomRegistry } from "./atom-registry";13import { appAtomRegistry } from "./atom-registry";
13import { getEnvironmentClient } from "./environment-session-registry";14import {
15 getEnvironmentClient,
16 subscribeEnvironmentConnections,
17} from "./environment-session-registry";
14 18
15export const gitBranchManager = createGitBranchManager({19export const gitBranchManager = createGitBranchManager({
16 getRegistry: () => appAtomRegistry,20 getRegistry: () => appAtomRegistry,
@@ -18,10 +22,21 @@ export const gitBranchManager = createGitBranchManager({ · 1 unmodified line
18 const client = getEnvironmentClient(environmentId);22 const client = getEnvironmentClient(environmentId);
19 return client ? client.git : null;23 return client ? client.git : null;
20 },24 },
25 subscribeClientChanges: subscribeEnvironmentConnections,
26 watchLimit: 100,
21});27});
22 28
23export function useGitBranches(target: GitBranchTarget): GitBranchState {29export function useGitBranches(target: GitBranchTarget): GitBranchState {
24 const targetKey = getGitBranchTargetKey(target);30 const stableTarget = useMemo(
31 () => ({
32 environmentId: target.environmentId,
33 cwd: target.cwd,
34 query: target.query ?? null,
35 }),
36 [target.cwd, target.environmentId, target.query],
37 );
38 const targetKey = getGitBranchTargetKey(stableTarget);
39 useEffect(() => gitBranchManager.watch(stableTarget), [stableTarget]);
25 const state = useAtomValue(40 const state = useAtomValue(
26 targetKey !== null ? gitBranchStateAtom(targetKey) : EMPTY_GIT_BRANCH_ATOM,41 targetKey !== null ? gitBranchStateAtom(targetKey) : EMPTY_GIT_BRANCH_ATOM,
27 );42 );
apps/mobile/src/state/use-remote-environment-registry.ts+1−1
@@ -31,7 +31,7 @@ import { type ConnectedEnvironmentSummary } from "./remote-runtime-types"; · 30 unmodified lines
31import {31import {
32 invalidateSourceControlDiscoveryForEnvironment,32 invalidateSourceControlDiscoveryForEnvironment,
33 resetSourceControlDiscoveryState,33 resetSourceControlDiscoveryState,
34} from "./source-control-discovery-manager";34} from "./use-source-control-discovery";
35import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";35import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";
36import { shellSnapshotManager } from "./use-shell-snapshot";36import { shellSnapshotManager } from "./use-shell-snapshot";
37import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session";37import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session";
apps/mobile/src/state/use-source-control-discovery.ts+51−23
@@ -2,44 +2,72 @@ import { useAtomValue } from "@effect/atom-react"; · 1 unmodified line
2import {2import {
3 EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,3 EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,
4 EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,4 EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
5 type SourceControlDiscoveryClient,
5 type SourceControlDiscoveryState,6 type SourceControlDiscoveryState,
7 type SourceControlDiscoveryTarget,
8 createSourceControlDiscoveryManager,
6 getSourceControlDiscoveryTargetKey,9 getSourceControlDiscoveryTargetKey,
7 sourceControlDiscoveryStateAtom,10 sourceControlDiscoveryStateAtom,
8} from "@t3tools/client-runtime";11} from "@t3tools/client-runtime";
9import type { EnvironmentId } from "@t3tools/contracts";12import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts";
10import { useEffect } from "react";13import { useEffect, useMemo } from "react";
11 14
15import { appAtomRegistry } from "./atom-registry";
12import {16import {
13 getEnvironmentClient,17 getEnvironmentClient,
14 subscribeEnvironmentConnections,18 subscribeEnvironmentConnections,
15} from "./environment-session-registry";19} from "./environment-session-registry";
16import {
17 refreshSourceControlDiscoveryForEnvironment,
18 sourceControlDiscoveryTargetForEnvironment,
19} from "./source-control-discovery-manager";
20 20
21export function useSourceControlDiscovery(21const sourceControlDiscoveryManager = createSourceControlDiscoveryManager<EnvironmentId>({
22 getRegistry: () => appAtomRegistry,
23 getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null,
24 subscribeClientChanges: subscribeEnvironmentConnections,
25});
26
27function sourceControlDiscoveryTargetForEnvironment(
22 environmentId: EnvironmentId | null,28 environmentId: EnvironmentId | null,
23): SourceControlDiscoveryState {29): SourceControlDiscoveryTarget<EnvironmentId> {
24 useEffect(() => {30 return { key: environmentId ?? null };
25 if (!environmentId) {31}
26 return;32
27 }33export function refreshSourceControlDiscoveryForEnvironment(
34 environmentId: EnvironmentId | null,
35 client?: SourceControlDiscoveryClient | null,
36): Promise<SourceControlDiscoveryResult | null> {
37 return sourceControlDiscoveryManager.refresh(
38 sourceControlDiscoveryTargetForEnvironment(environmentId),
39 client ?? undefined,
40 );
41}
42
43export function invalidateSourceControlDiscoveryForEnvironment(
44 environmentId: EnvironmentId | null,
45): void {
46 sourceControlDiscoveryManager.invalidate(
47 sourceControlDiscoveryTargetForEnvironment(environmentId),
48 );
49}
28 50
29 const refresh = () => {51export function resetSourceControlDiscoveryState(): void {
30 const client = getEnvironmentClient(environmentId);52 sourceControlDiscoveryManager.reset();
31 if (!client) {53}
32 return;
33 }
34 54
35 void refreshSourceControlDiscoveryForEnvironment(environmentId, client.server);55export function resetSourceControlDiscoveryStateForTests(): void {
36 };56 resetSourceControlDiscoveryState();
57}
37 58
38 refresh();59export function useSourceControlDiscovery(
39 return subscribeEnvironmentConnections(refresh);60 environmentId: EnvironmentId | null,
40 }, [environmentId]);61): SourceControlDiscoveryState {
62 const target = useMemo(
63 () => sourceControlDiscoveryTargetForEnvironment(environmentId),
64 [environmentId],
65 );
66
67 useEffect(() => {
68 return sourceControlDiscoveryManager.watch(target);
69 }, [target]);
41 70
42 const target = sourceControlDiscoveryTargetForEnvironment(environmentId);
43 const targetKey = getSourceControlDiscoveryTargetKey(target);71 const targetKey = getSourceControlDiscoveryTargetKey(target);
44 const state = useAtomValue(72 const state = useAtomValue(
45 targetKey !== null73 targetKey !== null
apps/mobile/src/state/use-thread-detail.ts+1−1
@@ -39,7 +39,7 @@ function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean { · 38 unmodified lines
39 );39 );
40}40}
41 41
42export const threadDetailManager = createThreadDetailManager({42const threadDetailManager = createThreadDetailManager({
43 getRegistry: () => appAtomRegistry,43 getRegistry: () => appAtomRegistry,
44 getClient: (environmentId) => {44 getClient: (environmentId) => {
45 const client = getEnvironmentClient(environmentId);45 const client = getEnvironmentClient(environmentId);
apps/web/src/components/chat/ChatComposer.tsx+9−28
@@ -2,7 +2,6 @@ import type { · 1 unmodified line
2 ApprovalRequestId,2 ApprovalRequestId,
3 EnvironmentId,3 EnvironmentId,
4 ModelSelection,4 ModelSelection,
5 ProjectEntry,
6 ProviderApprovalDecision,5 ProviderApprovalDecision,
7 ProviderInteractionMode,6 ProviderInteractionMode,
8 ResolvedKeybindingsConfig,7 ResolvedKeybindingsConfig,
@@ -29,9 +28,6 @@ import { · 20 unmodified lines
29 useRef,28 useRef,
30 useState,29 useState,
31} from "react";30} from "react";
32import { useQuery } from "@tanstack/react-query";
33import { useDebouncedValue } from "@tanstack/react-pacer";
34import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
35import {31import {
36 clampCollapsedComposerCursor,32 clampCollapsedComposerCursor,
37 type ComposerTrigger,33 type ComposerTrigger,
@@ -55,6 +51,7 @@ import { · 17 unmodified lines
55 insertInlineTerminalContextPlaceholder,51 insertInlineTerminalContextPlaceholder,
56 removeInlineTerminalContextPlaceholder,52 removeInlineTerminalContextPlaceholder,
57} from "../../lib/terminalContext";53} from "../../lib/terminalContext";
54import { useComposerPathSearch } from "../../lib/composerPathSearchState";
58import {55import {
59 shouldUseCompactComposerPrimaryActions,56 shouldUseCompactComposerPrimaryActions,
60 shouldUseCompactComposerFooter,57 shouldUseCompactComposerFooter,
@@ -136,8 +133,6 @@ const runtimeModeConfig: Record< · 75 unmodified lines
136};133};
137 134
138const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];135const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
139const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
140const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
141const COMPOSER_FLOATING_LAYER_SELECTOR = [136const COMPOSER_FLOATING_LAYER_SELECTOR = [
142 '[data-slot="popover-popup"]',137 '[data-slot="popover-popup"]',
143 '[data-slot="menu-popup"]',138 '[data-slot="menu-popup"]',
@@ -833,27 +828,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 689 unmodified lines
833 const composerTriggerKind = composerTrigger?.kind ?? null;828 const composerTriggerKind = composerTrigger?.kind ?? null;
834 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";829 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
835 const isPathTrigger = composerTriggerKind === "path";830 const isPathTrigger = composerTriggerKind === "path";
836 const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(831 const workspaceEntries = useComposerPathSearch({
837 pathTriggerQuery,832 environmentId,
838 { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },833 cwd: isPathTrigger ? gitCwd : null,
839 (debouncerState) => ({ isPending: debouncerState.isPending }),834 query: isPathTrigger ? pathTriggerQuery : null,
840 );835 });
841 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
842 const workspaceEntriesQuery = useQuery(
843 projectSearchEntriesQueryOptions({
844 environmentId,
845 cwd: gitCwd,
846 query: effectivePathQuery,
847 enabled: isPathTrigger,
848 limit: 80,
849 }),
850 );
851 const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
852 836
853 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {837 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
854 if (!composerTrigger) return [];838 if (!composerTrigger) return [];
855 if (composerTrigger.kind === "path") {839 if (composerTrigger.kind === "path") {
856 return workspaceEntries.map((entry) => ({840 return workspaceEntries.entries.map((entry) => ({
857 id: `path:${entry.kind}:${entry.path}`,841 id: `path:${entry.kind}:${entry.path}`,
858 type: "path",842 type: "path",
859 path: entry.path,843 path: entry.path,
@@ -919,7 +903,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 59 unmodified lines
919 );903 );
920 }904 }
921 return [];905 return [];
922 }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]);906 }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]);
923 907
924 const composerMenuOpen = Boolean(composerTrigger);908 const composerMenuOpen = Boolean(composerTrigger);
925 const composerMenuSearchKey = composerTrigger909 const composerMenuSearchKey = composerTrigger
@@ -984,10 +968,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 58 unmodified lines
984 ]);968 ]);
985 969
986 const isComposerMenuLoading =970 const isComposerMenuLoading =
987 composerTriggerKind === "path" &&971 composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending;
988 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
989 workspaceEntriesQuery.isLoading ||
990 workspaceEntriesQuery.isFetching);
991 const composerMenuEmptyState = useMemo(() => {972 const composerMenuEmptyState = useMemo(() => {
992 if (composerTriggerKind === "skill") {973 if (composerTriggerKind === "skill") {
993 return "No skills found. Try / to browse provider commands.";974 return "No skills found. Try / to browse provider commands.";
apps/web/src/lib/composerPathSearchState.ts+48−0
@@ -0,0 +1,48 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 type ComposerPathSearchState,
4 type ComposerPathSearchTarget,
5 EMPTY_COMPOSER_PATH_SEARCH_ATOM,
6 EMPTY_COMPOSER_PATH_SEARCH_STATE,
7 composerPathSearchStateAtom,
8 createComposerPathSearchManager,
9 getComposerPathSearchTargetKey,
10 normalizeComposerPathSearchQuery,
11} from "@t3tools/client-runtime";
12import { useEffect, useMemo } from "react";
13
14import {
15 readEnvironmentConnection,
16 subscribeEnvironmentConnections,
17} from "../environments/runtime";
18import { appAtomRegistry } from "../rpc/atomRegistry";
19
20const COMPOSER_PATH_SEARCH_LIMIT = 80;
21const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120;
22
23const composerPathSearchManager = createComposerPathSearchManager({
24 getRegistry: () => appAtomRegistry,
25 getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.projects ?? null,
26 subscribeClientChanges: subscribeEnvironmentConnections,
27 limit: COMPOSER_PATH_SEARCH_LIMIT,
28 debounceMs: COMPOSER_PATH_SEARCH_DEBOUNCE_MS,
29});
30
31export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState {
32 const stableTarget = useMemo(
33 () => ({
34 environmentId: target.environmentId,
35 cwd: target.cwd,
36 query: normalizeComposerPathSearchQuery(target.query),
37 }),
38 [target.cwd, target.environmentId, target.query],
39 );
40 const targetKey = getComposerPathSearchTargetKey(stableTarget);
41
42 useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]);
43
44 const state = useAtomValue(
45 targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM,
46 );
47 return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state;
48}
apps/web/src/lib/sourceControlDiscoveryState.ts+1−1
@@ -35,7 +35,7 @@ function sourceControlDiscoveryTarget( · 34 unmodified lines
35 : { key: environmentId };35 : { key: environmentId };
36}36}
37 37
38export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({38const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({
39 getRegistry: () => appAtomRegistry,39 getRegistry: () => appAtomRegistry,
40 getClient: (key) => {40 getClient: (key) => {
41 if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) {41 if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) {
bun.lock+3−0
@@ -339,7 +339,10 @@ · 338 unmodified lines
339 "node-pty",339 "node-pty",
340 ],340 ],
341 "patchedDependencies": {341 "patchedDependencies": {
342 "expo-dev-launcher@55.0.28": "patches/expo-dev-launcher@55.0.28.patch",
343 "react-native-nitro-modules@0.35.4": "patches/react-native-nitro-modules@0.35.4.patch",
342 "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch",344 "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch",
345 "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch",
343 },346 },
344 "overrides": {347 "overrides": {
345 "@effect/atom-react": "catalog:",348 "@effect/atom-react": "catalog:",
package.json+4−1
@@ -92,7 +92,10 @@ · 91 unmodified lines
92 ]92 ]
93 },93 },
94 "patchedDependencies": {94 "patchedDependencies": {
95 "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch"95 "effect@4.0.0-beta.59": "patches/effect@4.0.0-beta.59.patch",
96 "expo-dev-launcher@55.0.28": "patches/expo-dev-launcher@55.0.28.patch",
97 "react-native-nitro-modules@0.35.4": "patches/react-native-nitro-modules@0.35.4.patch",
98 "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch"
96 },99 },
97 "trustedDependencies": [100 "trustedDependencies": [
98 "node-pty",101 "node-pty",
packages/client-runtime/src/composerPathSearchState.test.ts+111−0
@@ -0,0 +1,111 @@
1import { assert, beforeEach, it } from "vitest";
2import type { EnvironmentId } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/unstable/reactivity";
4
5import {
6 type ComposerPathSearchClient,
7 createComposerPathSearchManager,
8 EMPTY_COMPOSER_PATH_SEARCH_STATE,
9 getComposerPathSearchTargetKey,
10} from "./composerPathSearchState.ts";
11
12let registry = AtomRegistry.make();
13
14const noop = () => undefined;
15
16beforeEach(() => {
17 registry.dispose();
18 registry = AtomRegistry.make();
19});
20
21function flushAsyncWork(): Promise<void> {
22 return Promise.resolve().then(() => undefined);
23}
24
25const TARGET = {
26 environmentId: "env-local" as EnvironmentId,
27 cwd: "/repo",
28 query: "src",
29};
30
31it("derives null keys for inactive path searches", () => {
32 assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, query: "" }), null);
33 assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, cwd: null }), null);
34 assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, environmentId: null }), null);
35});
36
37it("stores path search results in atom state", async () => {
38 const manager = createComposerPathSearchManager({
39 getRegistry: () => registry,
40 debounceMs: 0,
41 getClient: () => ({
42 searchEntries: async () => ({
43 entries: [
44 { path: "src/index.ts", kind: "file" },
45 { path: "src/components", kind: "directory" },
46 ],
47 truncated: false,
48 }),
49 }),
50 });
51
52 manager.search(TARGET);
53 await flushAsyncWork();
54
55 assert.deepStrictEqual(manager.getSnapshot(TARGET), {
56 entries: [
57 { path: "src/index.ts", kind: "file" },
58 { path: "src/components", kind: "directory" },
59 ],
60 isPending: false,
61 error: null,
62 });
63});
64
65it("ignores stale path search results after a newer request starts", async () => {
66 let resolveFirst: (value: {
67 entries: ReadonlyArray<{ path: string; kind: "file" | "directory" }>;
68 truncated: boolean;
69 }) => void = noop;
70 const manager = createComposerPathSearchManager({
71 getRegistry: () => registry,
72 debounceMs: 0,
73 getClient: () => ({
74 searchEntries: (input: Parameters<ComposerPathSearchClient["searchEntries"]>[0]) => {
75 if (input.query === "first") {
76 return new Promise((resolve) => {
77 resolveFirst = resolve;
78 });
79 }
80 return Promise.resolve({
81 entries: [{ path: "second.ts", kind: "file" }],
82 truncated: false,
83 });
84 },
85 }),
86 });
87
88 manager.search({ ...TARGET, query: "first" });
89 manager.search({ ...TARGET, query: "second" });
90 await flushAsyncWork();
91 resolveFirst({ entries: [{ path: "first.ts", kind: "file" }], truncated: false });
92 await flushAsyncWork();
93
94 assert.deepStrictEqual(manager.getSnapshot({ ...TARGET, query: "second" }), {
95 entries: [{ path: "second.ts", kind: "file" }],
96 isPending: false,
97 error: null,
98 });
99});
100
101it("returns the empty snapshot for inactive targets", () => {
102 const manager = createComposerPathSearchManager({
103 getRegistry: () => registry,
104 getClient: () => null,
105 });
106
107 assert.deepStrictEqual(
108 manager.getSnapshot({ environmentId: null, cwd: null, query: null }),
109 EMPTY_COMPOSER_PATH_SEARCH_STATE,
110 );
111});
packages/client-runtime/src/composerPathSearchState.ts+288−0
@@ -0,0 +1,288 @@
1import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
6
7export interface ComposerPathSearchEntry {
8 readonly path: string;
9 readonly kind: "file" | "directory";
10 readonly parentPath?: string;
11}
12
13export interface ComposerPathSearchState {
14 readonly entries: ReadonlyArray<ComposerPathSearchEntry>;
15 readonly isPending: boolean;
16 readonly error: string | null;
17}
18
19export interface ComposerPathSearchTarget {
20 readonly environmentId: EnvironmentId | null;
21 readonly cwd: string | null;
22 readonly query: string | null;
23}
24
25export interface ComposerPathSearchClient {
26 readonly searchEntries: (input: {
27 readonly cwd: string;
28 readonly query: string;
29 readonly limit: number;
30 }) => Promise<ProjectSearchEntriesResult>;
31}
32
33interface WatchedEntry {
34 refCount: number;
35 teardown: () => void;
36}
37
38export const EMPTY_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({
39 entries: [],
40 isPending: false,
41 error: null,
42});
43
44const PENDING_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({
45 entries: [],
46 isPending: true,
47 error: null,
48});
49
50const NOOP: () => void = () => undefined;
51const DEFAULT_DEBOUNCE_MS = 200;
52const DEFAULT_LIMIT = 20;
53
54export const composerPathSearchStateAtom = Atom.family((key: string) =>
55 Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe(
56 Atom.keepAlive,
57 Atom.withLabel(`composer-path-search:${key}`),
58 ),
59);
60
61export const EMPTY_COMPOSER_PATH_SEARCH_ATOM = Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe(
62 Atom.keepAlive,
63 Atom.withLabel("composer-path-search:null"),
64);
65
66export function normalizeComposerPathSearchQuery(query: string | null): string {
67 return query?.trim() ?? "";
68}
69
70export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget): string | null {
71 const query = normalizeComposerPathSearchQuery(target.query);
72 if (target.environmentId === null || target.cwd === null || query.length === 0) {
73 return null;
74 }
75
76 return `${target.environmentId}:${target.cwd}:${query}`;
77}
78
79function toSearchEntries(
80 entries: ProjectSearchEntriesResult["entries"],
81): ReadonlyArray<ComposerPathSearchEntry> {
82 return entries.map((entry) => ({
83 path: entry.path,
84 kind: entry.kind === "directory" ? "directory" : "file",
85 ...(entry.parentPath !== undefined ? { parentPath: entry.parentPath } : {}),
86 }));
87}
88
89export function createComposerPathSearchManager(config: {
90 readonly getRegistry: () => AtomRegistry.AtomRegistry;
91 readonly getClient: (environmentId: EnvironmentId) => ComposerPathSearchClient | null;
92 readonly subscribeClientChanges?: (listener: () => void) => () => void;
93 readonly debounceMs?: number;
94 readonly limit?: number;
95}) {
96 const watched = new Map<string, WatchedEntry>();
97 const versions = new Map<string, number>();
98 const timers = new Map<string, Fiber.Fiber<void, never>>();
99 const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS;
100 const limit = config.limit ?? DEFAULT_LIMIT;
101
102 function bumpVersion(targetKey: string): number {
103 const next = (versions.get(targetKey) ?? 0) + 1;
104 versions.set(targetKey, next);
105 return next;
106 }
107
108 function setState(targetKey: string, state: ComposerPathSearchState): void {
109 config.getRegistry().set(composerPathSearchStateAtom(targetKey), state);
110 }
111
112 function clearTimer(targetKey: string): void {
113 const fiber = timers.get(targetKey);
114 if (fiber) {
115 Effect.runFork(Fiber.interrupt(fiber));
116 timers.delete(targetKey);
117 }
118 }
119
120 function getSnapshot(target: ComposerPathSearchTarget): ComposerPathSearchState {
121 const targetKey = getComposerPathSearchTargetKey(target);
122 return targetKey === null
123 ? EMPTY_COMPOSER_PATH_SEARCH_STATE
124 : config.getRegistry().get(composerPathSearchStateAtom(targetKey));
125 }
126
127 function runSearch(
128 targetKey: string,
129 target: ComposerPathSearchTarget & {
130 readonly environmentId: EnvironmentId;
131 readonly cwd: string;
132 },
133 client: ComposerPathSearchClient,
134 version: number,
135 ): void {
136 void client
137 .searchEntries({
138 cwd: target.cwd,
139 query: normalizeComposerPathSearchQuery(target.query),
140 limit,
141 })
142 .then((result) => {
143 if (versions.get(targetKey) !== version) {
144 return;
145 }
146 setState(targetKey, {
147 entries: toSearchEntries(result.entries),
148 isPending: false,
149 error: null,
150 });
151 })
152 .catch((error: unknown) => {
153 if (versions.get(targetKey) !== version) {
154 return;
155 }
156 setState(targetKey, {
157 entries: [],
158 isPending: false,
159 error: error instanceof Error ? error.message : "Failed to search project files.",
160 });
161 });
162 }
163
164 function search(target: ComposerPathSearchTarget, client?: ComposerPathSearchClient): void {
165 const targetKey = getComposerPathSearchTargetKey(target);
166 if (targetKey === null || target.environmentId === null || target.cwd === null) {
167 return;
168 }
169
170 const resolved = client ?? config.getClient(target.environmentId);
171 if (!resolved) {
172 setState(targetKey, {
173 entries: [],
174 isPending: false,
175 error: "Remote connection is not ready.",
176 });
177 return;
178 }
179
180 const version = bumpVersion(targetKey);
181 clearTimer(targetKey);
182 setState(targetKey, PENDING_COMPOSER_PATH_SEARCH_STATE);
183
184 const readyTarget = {
185 ...target,
186 environmentId: target.environmentId,
187 cwd: target.cwd,
188 };
189
190 if (debounceMs <= 0) {
191 runSearch(targetKey, readyTarget, resolved, version);
192 return;
193 }
194
195 const fiber = Effect.runFork(
196 Effect.sleep(Duration.millis(debounceMs)).pipe(
197 Effect.andThen(
198 Effect.sync(() => {
199 timers.delete(targetKey);
200 runSearch(targetKey, readyTarget, resolved, version);
201 }),
202 ),
203 ),
204 );
205 timers.set(targetKey, fiber);
206 }
207
208 function watch(target: ComposerPathSearchTarget): () => void {
209 const targetKey = getComposerPathSearchTargetKey(target);
210 if (targetKey === null || target.environmentId === null || target.cwd === null) {
211 return NOOP;
212 }
213
214 const existing = watched.get(targetKey);
215 if (existing) {
216 existing.refCount += 1;
217 return () => unwatch(targetKey);
218 }
219
220 let currentClient: ComposerPathSearchClient | null = null;
221 const sync = () => {
222 const client = config.getClient(target.environmentId!);
223 if (!client) {
224 currentClient = null;
225 setState(targetKey, {
226 entries: [],
227 isPending: false,
228 error: "Remote connection is not ready.",
229 });
230 return;
231 }
232
233 if (currentClient === client) {
234 return;
235 }
236
237 currentClient = client;
238 search(target, client);
239 };
240
241 const unsubscribe = config.subscribeClientChanges?.(sync) ?? NOOP;
242 sync();
243
244 watched.set(targetKey, {
245 refCount: 1,
246 teardown: () => {
247 unsubscribe();
248 clearTimer(targetKey);
249 bumpVersion(targetKey);
250 },
251 });
252
253 return () => unwatch(targetKey);
254 }
255
256 function unwatch(targetKey: string): void {
257 const entry = watched.get(targetKey);
258 if (!entry) {
259 return;
260 }
261
262 entry.refCount -= 1;
263 if (entry.refCount > 0) {
264 return;
265 }
266
267 entry.teardown();
268 watched.delete(targetKey);
269 }
270
271 function reset(): void {
272 for (const entry of watched.values()) {
273 entry.teardown();
274 }
275 watched.clear();
276 versions.clear();
277 for (const targetKey of timers.keys()) {
278 clearTimer(targetKey);
279 }
280 }
281
282 return {
283 getSnapshot,
284 search,
285 watch,
286 reset,
287 };
288}
packages/client-runtime/src/gitBranchState.test.ts+33−0
@@ -16,6 +16,8 @@ function resetAtomRegistry() { · 15 unmodified lines
16 atomRegistry = AtomRegistry.make();16 atomRegistry = AtomRegistry.make();
17}17}
18 18
19const noop = () => undefined;
20
19const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const;21const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const;
20 22
21const FIRST_PAGE: GitListBranchesResult = {23const FIRST_PAGE: GitListBranchesResult = {
@@ -158,4 +160,35 @@ describe("createGitBranchManager", () => { · 136 unmodified lines
158 160
159 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_GIT_BRANCH_STATE);161 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_GIT_BRANCH_STATE);
160 });162 });
163
164 it("watches branches with a ref-counted client-change subscription", async () => {
165 const mock = createMockClient();
166 let listener: () => void = noop;
167 const unsubscribe = vi.fn();
168 const manager = createGitBranchManager({
169 getRegistry: () => atomRegistry,
170 getClient: () => mock.client,
171 subscribeClientChanges: (nextListener) => {
172 listener = nextListener;
173 return unsubscribe;
174 },
175 watchLimit: 100,
176 });
177
178 const firstUnwatch = manager.watch(TARGET);
179 const secondUnwatch = manager.watch(TARGET);
180 await Promise.resolve();
181
182 expect(mock.listRefs).toHaveBeenCalledTimes(1);
183 expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 });
184
185 listener();
186 await Promise.resolve();
187 expect(mock.listRefs).toHaveBeenCalledTimes(1);
188
189 firstUnwatch();
190 expect(unsubscribe).not.toHaveBeenCalled();
191 secondUnwatch();
192 expect(unsubscribe).toHaveBeenCalledTimes(1);
193 });
161});194});
packages/client-runtime/src/gitBranchState.ts+81−0
@@ -82,10 +82,22 @@ function mergeBranches( · 81 unmodified lines
82export interface GitBranchManagerConfig {82export interface GitBranchManagerConfig {
83 readonly getRegistry: () => AtomRegistry.AtomRegistry;83 readonly getRegistry: () => AtomRegistry.AtomRegistry;
84 readonly getClient: (environmentId: EnvironmentId) => GitBranchClient | null;84 readonly getClient: (environmentId: EnvironmentId) => GitBranchClient | null;
85 readonly subscribeClientChanges?: (listener: () => void) => () => void;
86 readonly watchLimit?: number;
85}87}
86 88
89interface WatchedEntry {
90 refCount: number;
91 teardown: () => void;
92}
93
94const NOOP: () => void = () => undefined;
95
87export function createGitBranchManager(config: GitBranchManagerConfig) {96export function createGitBranchManager(config: GitBranchManagerConfig) {
88 const inFlight = new Map<string, Promise<GitListBranchesResult | null>>();97 const inFlight = new Map<string, Promise<GitListBranchesResult | null>>();
98 const watched = new Map<string, WatchedEntry>();
99 const watchLoadOptions =
100 config.watchLimit === undefined ? undefined : { limit: config.watchLimit };
89 101
90 function getSnapshot(target: GitBranchTarget): GitBranchState {102 function getSnapshot(target: GitBranchTarget): GitBranchState {
91 const targetKey = getGitBranchTargetKey(target);103 const targetKey = getGitBranchTargetKey(target);
@@ -204,6 +216,70 @@ export function createGitBranchManager(config: GitBranchManagerConfig) { · 112 unmodified lines
204 });216 });
205 }217 }
206 218
219 function watch(target: GitBranchTarget, client?: GitBranchClient): () => void {
220 const targetKey = getGitBranchTargetKey(target);
221 if (targetKey === null || target.environmentId === null || target.cwd === null) {
222 return NOOP;
223 }
224
225 const existing = watched.get(targetKey);
226 if (existing) {
227 existing.refCount += 1;
228 return () => unwatch(targetKey);
229 }
230
231 let teardown: () => void;
232
233 if (client) {
234 void load(target, client, watchLoadOptions);
235 teardown = NOOP;
236 } else if (config.subscribeClientChanges) {
237 let currentClient: GitBranchClient | null = null;
238 const sync = () => {
239 const resolved = config.getClient(target.environmentId!);
240 if (!resolved) {
241 currentClient = null;
242 return;
243 }
244 if (currentClient === resolved) {
245 return;
246 }
247
248 currentClient = resolved;
249 void load(target, resolved, watchLoadOptions);
250 };
251
252 const unsubscribe = config.subscribeClientChanges(sync);
253 sync();
254 teardown = unsubscribe;
255 } else {
256 const resolved = config.getClient(target.environmentId);
257 if (!resolved) {
258 return NOOP;
259 }
260 void load(target, resolved, watchLoadOptions);
261 teardown = NOOP;
262 }
263
264 watched.set(targetKey, { refCount: 1, teardown });
265 return () => unwatch(targetKey);
266 }
267
268 function unwatch(targetKey: string): void {
269 const entry = watched.get(targetKey);
270 if (!entry) {
271 return;
272 }
273
274 entry.refCount -= 1;
275 if (entry.refCount > 0) {
276 return;
277 }
278
279 entry.teardown();
280 watched.delete(targetKey);
281 }
282
207 function invalidate(target?: GitBranchTarget): void {283 function invalidate(target?: GitBranchTarget): void {
208 if (target) {284 if (target) {
209 const targetKey = getGitBranchTargetKey(target);285 const targetKey = getGitBranchTargetKey(target);
@@ -219,11 +295,16 @@ export function createGitBranchManager(config: GitBranchManagerConfig) { · 9 unmodified lines
219 }295 }
220 296
221 function reset(): void {297 function reset(): void {
298 for (const entry of watched.values()) {
299 entry.teardown();
300 }
301 watched.clear();
222 invalidate();302 invalidate();
223 }303 }
224 304
225 return {305 return {
226 getSnapshot,306 getSnapshot,
307 watch,
227 load,308 load,
228 loadNext,309 loadNext,
229 invalidate,310 invalidate,
packages/client-runtime/src/index.ts+1−0
@@ -19,3 +19,4 @@ export * from "./wsRpcProtocol.ts"; · 18 unmodified lines
19export * from "./wsTransport.ts";19export * from "./wsTransport.ts";
20export * from "./wsRpcClient.ts";20export * from "./wsRpcClient.ts";
21export * from "./environmentConnection.ts";21export * from "./environmentConnection.ts";
22export * from "./composerPathSearchState.ts";
packages/client-runtime/src/sourceControlDiscoveryState.test.ts+102−0
@@ -50,11 +50,17 @@ function unresolvedDiscovery() { · 49 unmodified lines
50 50
51let registry = AtomRegistry.make();51let registry = AtomRegistry.make();
52 52
53const noop = () => undefined;
54
53beforeEach(() => {55beforeEach(() => {
54 registry.dispose();56 registry.dispose();
55 registry = AtomRegistry.make();57 registry = AtomRegistry.make();
56});58});
57 59
60function flushAsyncWork(): Promise<void> {
61 return Promise.resolve().then(() => undefined);
62}
63
58it("stores refreshed discovery data in an atom snapshot", async () => {64it("stores refreshed discovery data in an atom snapshot", async () => {
59 const manager = createSourceControlDiscoveryManager({65 const manager = createSourceControlDiscoveryManager({
60 getRegistry: () => registry,66 getRegistry: () => registry,
@@ -181,3 +187,99 @@ it("ignores an in-flight refresh after the target is invalidated", async () => { · 120 unmodified lines
181 isPending: true,187 isPending: true,
182 });188 });
183});189});
190
191it("watches a discovery target with ref-counted client-change subscriptions", async () => {
192 let listener: () => void = noop;
193 let subscribeCalls = 0;
194 let unsubscribeCalls = 0;
195 let discoveryCalls = 0;
196 const client = {
197 discoverSourceControl: async () => {
198 discoveryCalls += 1;
199 return EMPTY_RESULT;
200 },
201 };
202 const manager = createSourceControlDiscoveryManager({
203 getRegistry: () => registry,
204 getClient: () => client,
205 subscribeClientChanges: (nextListener) => {
206 subscribeCalls += 1;
207 listener = nextListener;
208 return () => {
209 unsubscribeCalls += 1;
210 };
211 },
212 });
213
214 const firstUnwatch = manager.watch({ key: "primary" });
215 const secondUnwatch = manager.watch({ key: "primary" });
216 await flushAsyncWork();
217
218 assert.strictEqual(subscribeCalls, 1);
219 assert.strictEqual(discoveryCalls, 1);
220 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
221 data: EMPTY_RESULT,
222 error: null,
223 isPending: false,
224 });
225
226 listener();
227 await flushAsyncWork();
228 assert.strictEqual(discoveryCalls, 1);
229
230 firstUnwatch();
231 assert.strictEqual(unsubscribeCalls, 0);
232
233 secondUnwatch();
234 assert.strictEqual(unsubscribeCalls, 1);
235});
236
237it("refreshes a watched discovery target when the resolved client is replaced", async () => {
238 let listener: () => void = noop;
239 let activeResult = EMPTY_RESULT;
240 let discoveryCalls = 0;
241 const firstClient = {
242 discoverSourceControl: async () => {
243 discoveryCalls += 1;
244 return activeResult;
245 },
246 };
247 const secondClient = {
248 discoverSourceControl: async () => {
249 discoveryCalls += 1;
250 return activeResult;
251 },
252 };
253 let activeClient = firstClient;
254 const manager = createSourceControlDiscoveryManager({
255 getRegistry: () => registry,
256 getClient: () => activeClient,
257 subscribeClientChanges: (nextListener) => {
258 listener = nextListener;
259 return () => undefined;
260 },
261 });
262
263 const unwatch = manager.watch({ key: "primary" });
264 await flushAsyncWork();
265
266 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
267 data: EMPTY_RESULT,
268 error: null,
269 isPending: false,
270 });
271
272 activeClient = secondClient;
273 activeResult = GITHUB_RESULT;
274 listener();
275 await flushAsyncWork();
276
277 assert.strictEqual(discoveryCalls, 2);
278 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
279 data: GITHUB_RESULT,
280 error: null,
281 isPending: false,
282 });
283
284 unwatch();
285});
packages/client-runtime/src/sourceControlDiscoveryState.ts+118−8
@@ -17,6 +17,11 @@ export interface SourceControlDiscoveryClient { · 16 unmodified lines
17 readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;17 readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
18}18}
19 19
20interface WatchedEntry {
21 refCount: number;
22 teardown: () => void;
23}
24
20/* --- Constants ------------------------------------------------------ */25/* --- Constants ------------------------------------------------------ */
21 26
22export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({27export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
@@ -71,13 +76,29 @@ export interface SourceControlDiscoveryManagerConfig<TKey extends string = strin · 48 unmodified lines
71 * clients without changing the state primitive.76 * clients without changing the state primitive.
72 */77 */
73 readonly getClient: (key: TKey) => SourceControlDiscoveryClient | null;78 readonly getClient: (key: TKey) => SourceControlDiscoveryClient | null;
79 /**
80 * Optional: subscribe to environment/client availability changes.
81 *
82 * When provided, `watch` refreshes as clients appear or are replaced
83 * instead of relying on React hooks to manually kick discovery.
84 */
85 readonly subscribeClientChanges?: (listener: () => void) => () => void;
74}86}
75 87
88const NOOP: () => void = () => undefined;
89
76export function createSourceControlDiscoveryManager<TKey extends string = string>(90export function createSourceControlDiscoveryManager<TKey extends string = string>(
77 config: SourceControlDiscoveryManagerConfig<TKey>,91 config: SourceControlDiscoveryManagerConfig<TKey>,
78) {92) {
79 const refreshInFlight = new Map<string, Promise<SourceControlDiscoveryResult | null>>();93 const refreshInFlight = new Map<
94 string,
95 {
96 readonly client: SourceControlDiscoveryClient;
97 readonly promise: Promise<SourceControlDiscoveryResult | null>;
98 }
99 >();
80 const refreshVersions = new Map<string, number>();100 const refreshVersions = new Map<string, number>();
101 const watched = new Map<string, WatchedEntry>();
81 102
82 function getRefreshVersion(targetKey: string): number {103 function getRefreshVersion(targetKey: string): number {
83 return refreshVersions.get(targetKey) ?? 0;104 return refreshVersions.get(targetKey) ?? 0;
@@ -154,11 +175,6 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 70 unmodified lines
154 return Promise.resolve(null);175 return Promise.resolve(null);
155 }176 }
156 177
157 const existing = refreshInFlight.get(targetKey);
158 if (existing) {
159 return existing;
160 }
161
162 const resolvedClient = client ?? config.getClient(targetKey);178 const resolvedClient = client ?? config.getClient(targetKey);
163 if (!resolvedClient) {179 if (!resolvedClient) {
164 const error = new Error("Source control discovery client is unavailable.");180 const error = new Error("Source control discovery client is unavailable.");
@@ -166,6 +182,15 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 1 unmodified line
166 return Promise.resolve(getSnapshot(target).data);182 return Promise.resolve(getSnapshot(target).data);
167 }183 }
168 184
185 const existing = refreshInFlight.get(targetKey);
186 if (existing) {
187 if (!client || existing.client === resolvedClient) {
188 return existing.promise;
189 }
190
191 return existing.promise.then(() => refresh(target, resolvedClient));
192 }
193
169 markPending(targetKey);194 markPending(targetKey);
170 const refreshVersion = getRefreshVersion(targetKey);195 const refreshVersion = getRefreshVersion(targetKey);
171 const promise = resolvedClient.discoverSourceControl().then(196 const promise = resolvedClient.discoverSourceControl().then(
@@ -184,11 +209,14 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 12 unmodified lines
184 );209 );
185 let tracked: Promise<SourceControlDiscoveryResult | null>;210 let tracked: Promise<SourceControlDiscoveryResult | null>;
186 tracked = promise.finally(() => {211 tracked = promise.finally(() => {
187 if (refreshInFlight.get(targetKey) === tracked) {212 if (refreshInFlight.get(targetKey)?.promise === tracked) {
188 refreshInFlight.delete(targetKey);213 refreshInFlight.delete(targetKey);
189 }214 }
190 });215 });
191 refreshInFlight.set(targetKey, tracked);216 refreshInFlight.set(targetKey, {
217 client: resolvedClient,
218 promise: tracked,
219 });
192 return tracked;220 return tracked;
193 }221 }
194 222
@@ -228,12 +256,93 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 33 unmodified lines
228 return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));256 return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));
229 }257 }
230 258
259 /**
260 * Keep discovery warm for `target`.
261 *
262 * Multiple callers sharing a target key are ref-counted. With
263 * `subscribeClientChanges`, the manager refreshes whenever a client first
264 * appears or is replaced after reconnect.
265 */
266 function watch(
267 target: SourceControlDiscoveryTarget<TKey>,
268 client?: SourceControlDiscoveryClient,
269 ): () => void {
270 const targetKey = getSourceControlDiscoveryTargetKey(target);
271 if (targetKey === null) {
272 return NOOP;
273 }
274
275 const existing = watched.get(targetKey);
276 if (existing) {
277 existing.refCount += 1;
278 return () => unwatch(targetKey);
279 }
280
281 let teardown: () => void;
282
283 if (client) {
284 void refresh(target, client);
285 teardown = NOOP;
286 } else if (config.subscribeClientChanges) {
287 let currentClient: SourceControlDiscoveryClient | null = null;
288
289 const sync = () => {
290 const resolved = config.getClient(targetKey);
291 if (!resolved) {
292 currentClient = null;
293 markPending(targetKey);
294 return;
295 }
296
297 if (currentClient === resolved) {
298 return;
299 }
300
301 currentClient = resolved;
302 void refresh(target, resolved);
303 };
304
305 const unsubChanges = config.subscribeClientChanges(sync);
306 sync();
307 teardown = unsubChanges;
308 } else {
309 const resolved = config.getClient(targetKey);
310 if (!resolved) {
311 return NOOP;
312 }
313 void refresh(target, resolved);
314 teardown = NOOP;
315 }
316
317 watched.set(targetKey, { refCount: 1, teardown });
318 return () => unwatch(targetKey);
319 }
320
321 function unwatch(targetKey: string): void {
322 const entry = watched.get(targetKey);
323 if (!entry) {
324 return;
325 }
326
327 entry.refCount -= 1;
328 if (entry.refCount > 0) {
329 return;
330 }
331
332 entry.teardown();
333 watched.delete(targetKey);
334 }
335
231 /**336 /**
232 * Clear in-flight refresh tracking and reset every known discovery atom.337 * Clear in-flight refresh tracking and reset every known discovery atom.
233 * Primarily used by tests and runtime teardown.338 * Primarily used by tests and runtime teardown.
234 */339 */
235 function reset(): void {340 function reset(): void {
236 const keys = new Set([...knownSourceControlDiscoveryKeys, ...refreshInFlight.keys()]);341 const keys = new Set([...knownSourceControlDiscoveryKeys, ...refreshInFlight.keys()]);
342 for (const entry of watched.values()) {
343 entry.teardown();
344 }
345 watched.clear();
237 refreshInFlight.clear();346 refreshInFlight.clear();
238 for (const key of keys) {347 for (const key of keys) {
239 bumpRefreshVersion(key);348 bumpRefreshVersion(key);
@@ -242,6 +351,7 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 2 unmodified lines
242 }351 }
243 352
244 return {353 return {
354 watch,
245 refresh,355 refresh,
246 getSnapshot,356 getSnapshot,
247 invalidate,357 invalidate,
patches/@pierre%2Fdiffs@1.1.20.patch+23−0
@@ -0,0 +1,23 @@
1diff --git a/package.json b/package.json
2index c0e762b13b56247b256520754e439cbc16bb797d..d298b87c175ec891d1b50f681c9031f4b1929e67 100644
3--- a/package.json
4+++ b/package.json
5@@ -51,6 +51,18 @@
6 "./worker/worker-portable.js": {
7 "types": "./dist/worker/worker-portable.d.ts",
8 "import": "./dist/worker/worker-portable.js"
9+ },
10+ "./types": {
11+ "types": "./dist/types.d.ts",
12+ "import": "./dist/types.js"
13+ },
14+ "./utils/getFiletypeFromFileName": {
15+ "types": "./dist/utils/getFiletypeFromFileName.d.ts",
16+ "import": "./dist/utils/getFiletypeFromFileName.js"
17+ },
18+ "./utils/parsePatchFiles": {
19+ "types": "./dist/utils/parsePatchFiles.d.ts",
20+ "import": "./dist/utils/parsePatchFiles.js"
21 }
22 },
23 "scripts": {
patches/expo-dev-launcher@55.0.28.patch+15−0
@@ -0,0 +1,15 @@
1diff --git a/ios/EXDevLauncherController.m b/ios/EXDevLauncherController.m
2index c9ca6fbfe758e6f97d6f05b0c88e382f0eebae2f..7ed12652528ab31350ecbc9cca736bcda7adf3f3 100644
3--- a/ios/EXDevLauncherController.m
4+++ b/ios/EXDevLauncherController.m
5@@ -625,7 +625,9 @@ -(void)copyToClipboard:(NSString *)content {
6 - (void)setDevMenuAppBridge
7 {
8 DevMenuManager *manager = [DevMenuManager shared];
9- [manager updateCurrentBridge:self.appBridge];
10+ // Expo SDK 55's dev launcher still references `self.appBridge`,
11+ // but React Native 0.83 removed that property from the delegate base class.
12+ // Newer Expo builds stop updating the dev menu bridge here.
13
14 if (self.manifest != nil) {
15 [manager updateCurrentManifest:self.manifest manifestURL:self.manifestURL];
patches/react-native-nitro-modules@0.35.4.patch+16−0
@@ -0,0 +1,16 @@
1diff --git a/package.json b/package.json
2index 4e4366fb9d05a6c250acfc62e79487475d8ac05b..200e4680d77cbb4f14d22eb986efde1275a0d3e8 100644
3--- a/package.json
4+++ b/package.json
5@@ -88,6 +88,11 @@
6 "jsSrcsDir": "./src",
7 "android": {
8 "javaPackageName": "com.margelo.nitro"
9+ },
10+ "ios": {
11+ "modulesProvider": {
12+ "NitroModules": "NativeNitroModules"
13+ }
14 }
15 },
16 "jest": {
scripts/mobile-native-static-check.ts+3−13
@@ -40,8 +40,10 @@ const sourceExtensions = new Set([".swift", ".kt", ".kts"]); · 39 unmodified lines
40const excludedDirectories = new Set([40const excludedDirectories = new Set([
41 ".expo",41 ".expo",
42 ".git",42 ".git",
43 "android",
43 "build",44 "build",
44 "DerivedData",45 "DerivedData",
46 "ios",
45 "node_modules",47 "node_modules",
46 "Pods",48 "Pods",
47 "Vendor",49 "Vendor",
@@ -188,19 +190,7 @@ const runNativeStaticChecks = Effect.fn("runNativeStaticChecks")(function* () { · 140 unmodified lines
188 }190 }
189 }191 }
190 192
191 const androidProject = path.join(root, "android");193 yield* Console.log("Skipping generated native project folders: android/, ios/.");
192 const gradlew = path.join(
193 androidProject,
194 process.platform === "win32" ? "gradlew.bat" : "gradlew",
195 );
196 const fs = yield* FileSystem.FileSystem;
197
198 if (yield* fs.exists(gradlew)) {
199 const stat = yield* fs.stat(gradlew);
200 if (stat.type === "File") {
201 yield* runCommand(gradlew, ["lint"], androidProject);
202 }
203 }
204});194});
205 195
206export const mobileNativeStaticCheckCommand = Command.make("mobile-native-static-check", {}, () =>196export const mobileNativeStaticCheckCommand = Command.make("mobile-native-static-check", {}, () =>
scripts/patch-expo-dev-launcher.mjs+0−63
@@ -1,63 +0,0 @@
1import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2import { join } from "node:path";
3
4const root = process.cwd();
5
6const candidates = [join(root, "node_modules/expo-dev-launcher/ios/EXDevLauncherController.m")];
7
8const bunStorePath = join(root, "node_modules/.bun");
9
10if (existsSync(bunStorePath)) {
11 for (const entry of readdirSync(bunStorePath)) {
12 if (!entry.startsWith("expo-dev-launcher@")) {
13 continue;
14 }
15
16 candidates.push(
17 join(bunStorePath, entry, "node_modules/expo-dev-launcher/ios/EXDevLauncherController.m"),
18 );
19 }
20}
21
22const targetLine = "[manager updateCurrentBridge:self.appBridge];";
23const replacement = [
24 " // Expo SDK 55's dev launcher still references `self.appBridge`,",
25 " // but React Native 0.83 removed that property from the delegate base class.",
26 " // Newer Expo builds stop updating the dev menu bridge here.",
27].join("\n");
28
29let patchedCount = 0;
30let locatedCount = 0;
31
32for (const filePath of candidates) {
33 if (!existsSync(filePath)) {
34 continue;
35 }
36
37 locatedCount += 1;
38 const source = readFileSync(filePath, "utf8");
39
40 if (source.includes(replacement)) {
41 continue;
42 }
43
44 if (!source.includes(targetLine)) {
45 throw new Error(
46 `Found expo-dev-launcher source at ${filePath}, but the expected bridge line was missing.`,
47 );
48 }
49
50 const nextSource = source.replace(` ${targetLine}`, replacement);
51 writeFileSync(filePath, nextSource);
52 patchedCount += 1;
53}
54
55if (patchedCount > 0) {
56 console.log(
57 `[patch-expo-dev-launcher] patched ${patchedCount} expo-dev-launcher installation(s).`,
58 );
59} else if (locatedCount > 0) {
60 console.log("[patch-expo-dev-launcher] expo-dev-launcher already patched.");
61} else {
62 console.log("[patch-expo-dev-launcher] expo-dev-launcher not installed; skipping.");
63}
scripts/patch-pierre-diffs-utils.mjs+0−105
@@ -1,105 +0,0 @@
1import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2import { join } from "node:path";
3
4const root = process.cwd();
5const bunStorePath = join(root, "node_modules/.bun");
6
7const packageDirs = [];
8
9if (existsSync(join(root, "node_modules/@pierre/diffs"))) {
10 packageDirs.push(join(root, "node_modules/@pierre/diffs"));
11}
12
13if (existsSync(bunStorePath)) {
14 for (const entry of readdirSync(bunStorePath)) {
15 if (!entry.startsWith("@pierre+diffs@")) {
16 continue;
17 }
18
19 packageDirs.push(join(bunStorePath, entry, "node_modules/@pierre/diffs"));
20 }
21}
22
23const utilsIndexJs = [
24 'export { parsePatchFiles, processFile, processPatch } from "./parsePatchFiles.js";',
25 "export {",
26 " EXTENSION_TO_FILE_FORMAT,",
27 " getCustomExtensionsMap,",
28 " getCustomExtensionsVersion,",
29 " getFiletypeFromFileName,",
30 " replaceCustomExtensions,",
31 " setCustomExtension,",
32 '} from "./getFiletypeFromFileName.js";',
33 "",
34].join("\n");
35
36const utilsIndexDts = [
37 'export { parsePatchFiles, processFile, processPatch } from "./parsePatchFiles.js";',
38 "export {",
39 " EXTENSION_TO_FILE_FORMAT,",
40 " getCustomExtensionsMap,",
41 " getCustomExtensionsVersion,",
42 " getFiletypeFromFileName,",
43 " replaceCustomExtensions,",
44 " setCustomExtension,",
45 '} from "./getFiletypeFromFileName.js";',
46 'export type { ChangeTypes, FileDiffMetadata, ParsedPatch, SupportedLanguages } from "../types.js";',
47 "",
48].join("\n");
49
50let patchedCount = 0;
51let locatedCount = 0;
52
53for (const packageDir of packageDirs) {
54 if (!existsSync(packageDir)) {
55 continue;
56 }
57
58 locatedCount += 1;
59
60 const packageJsonPath = join(packageDir, "package.json");
61 const utilsIndexJsPath = join(packageDir, "dist/utils/index.js");
62 const utilsIndexDtsPath = join(packageDir, "dist/utils/index.d.ts");
63
64 const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8"));
65 const currentUtilsExport = pkg.exports?.["./utils"];
66
67 const expectedUtilsExport = {
68 types: "./dist/utils/index.d.ts",
69 import: "./dist/utils/index.js",
70 };
71
72 const needsExportPatch =
73 currentUtilsExport?.types !== expectedUtilsExport.types ||
74 currentUtilsExport?.import !== expectedUtilsExport.import;
75
76 if (needsExportPatch) {
77 pkg.exports = {
78 ...pkg.exports,
79 "./utils": expectedUtilsExport,
80 };
81 writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
82 }
83
84 const currentJs = existsSync(utilsIndexJsPath) ? readFileSync(utilsIndexJsPath, "utf8") : null;
85 if (currentJs !== utilsIndexJs) {
86 writeFileSync(utilsIndexJsPath, utilsIndexJs);
87 }
88
89 const currentDts = existsSync(utilsIndexDtsPath) ? readFileSync(utilsIndexDtsPath, "utf8") : null;
90 if (currentDts !== utilsIndexDts) {
91 writeFileSync(utilsIndexDtsPath, utilsIndexDts);
92 }
93
94 if (needsExportPatch || currentJs !== utilsIndexJs || currentDts !== utilsIndexDts) {
95 patchedCount += 1;
96 }
97}
98
99if (patchedCount > 0) {
100 console.log(`[patch-pierre-diffs-utils] patched ${patchedCount} @pierre/diffs installation(s).`);
101} else if (locatedCount > 0) {
102 console.log("[patch-pierre-diffs-utils] @pierre/diffs already patched.");
103} else {
104 console.log("[patch-pierre-diffs-utils] @pierre/diffs not installed; skipping.");
105}
scripts/patch-react-native-nitro-modules.mjs+0−73
@@ -1,73 +0,0 @@
1import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2import { join } from "node:path";
3
4const root = process.cwd();
5
6const candidates = [join(root, "node_modules/react-native-nitro-modules/package.json")];
7
8const bunStorePath = join(root, "node_modules/.bun");
9
10if (existsSync(bunStorePath)) {
11 for (const entry of readdirSync(bunStorePath)) {
12 if (!entry.startsWith("react-native-nitro-modules@")) {
13 continue;
14 }
15
16 candidates.push(
17 join(bunStorePath, entry, "node_modules/react-native-nitro-modules/package.json"),
18 );
19 }
20}
21
22let patchedCount = 0;
23let locatedCount = 0;
24
25for (const filePath of candidates) {
26 if (!existsSync(filePath)) {
27 continue;
28 }
29
30 locatedCount += 1;
31
32 const source = readFileSync(filePath, "utf8");
33 const pkg = JSON.parse(source);
34
35 if (pkg.codegenConfig == null || typeof pkg.codegenConfig !== "object") {
36 throw new Error(
37 `Found react-native-nitro-modules package at ${filePath}, but codegenConfig was missing.`,
38 );
39 }
40
41 const iosConfig =
42 pkg.codegenConfig.ios != null && typeof pkg.codegenConfig.ios === "object"
43 ? pkg.codegenConfig.ios
44 : {};
45
46 const modulesProvider =
47 iosConfig.modulesProvider != null && typeof iosConfig.modulesProvider === "object"
48 ? iosConfig.modulesProvider
49 : {};
50
51 if (modulesProvider.NitroModules === "NativeNitroModules") {
52 continue;
53 }
54
55 modulesProvider.NitroModules = "NativeNitroModules";
56 iosConfig.modulesProvider = modulesProvider;
57 pkg.codegenConfig.ios = iosConfig;
58
59 writeFileSync(filePath, `${JSON.stringify(pkg, null, 2)}\n`);
60 patchedCount += 1;
61}
62
63if (patchedCount > 0) {
64 console.log(
65 `[patch-react-native-nitro-modules] patched ${patchedCount} react-native-nitro-modules installation(s).`,
66 );
67} else if (locatedCount > 0) {
68 console.log("[patch-react-native-nitro-modules] react-native-nitro-modules already patched.");
69} else {
70 console.log(
71 "[patch-react-native-nitro-modules] react-native-nitro-modules not installed; skipping.",
72 );
73}