- 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
Unified · Split
| @@ -9,6 +9,8 @@ · 8 unmodified lines | ||
| 9 | 9 | "bun.lock", |
| 10 | 10 | "*.tsbuildinfo", |
| 11 | 11 | "**/routeTree.gen.ts", |
| 12 | + "apps/mobile/android/**", | |
| 13 | + "apps/mobile/ios/**", | |
| 12 | 14 | "apps/web/public/mockServiceWorker.js", |
| 13 | 15 | "apps/web/src/lib/vendor/qrcodegen.ts", |
| 14 | 16 | "apps/mobile/uniwind-types.d.ts", |
| @@ -7,6 +7,8 @@ · 6 unmodified lines | ||
| 7 | 7 | "bun.lock", |
| 8 | 8 | "*.tsbuildinfo", |
| 9 | 9 | "**/routeTree.gen.ts", |
| 10 | + "apps/mobile/android/**", | |
| 11 | + "apps/mobile/ios/**", | |
| 10 | 12 | "apps/mobile/uniwind-types.d.ts" |
| 11 | 13 | ], |
| 12 | 14 | "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"], |
| @@ -1,101 +1,15 @@ | ||
| 1 | −import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react"; | |
| 1 | +import { createContext, type ReactNode, useContext, useMemo } from "react"; | |
| 2 | 2 | |
| 3 | −import { | |
| 4 | − getActiveReviewHighlighterEngine, | |
| 5 | − prepareReviewHighlighter, | |
| 6 | − prepareReviewHighlighterLanguages, | |
| 7 | − type ReviewHighlighterEngine, | |
| 8 | −} from "./shikiReviewHighlighter"; | |
| 3 | +import { type ReviewHighlighterState, useReviewHighlighterState } from "./reviewHighlighterState"; | |
| 9 | 4 | |
| 10 | −type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error"; | |
| 11 | − | |
| 12 | −interface ReviewHighlighterContextValue { | |
| 13 | − readonly engine: ReviewHighlighterEngine | null; | |
| 14 | − readonly error: string | null; | |
| 15 | − readonly status: ReviewHighlighterStatus; | |
| 16 | −} | |
| 17 | − | |
| 18 | −const ReviewHighlighterContext = createContext<ReviewHighlighterContextValue>({ | |
| 5 | +const ReviewHighlighterContext = createContext<ReviewHighlighterState>({ | |
| 19 | 6 | engine: null, |
| 20 | 7 | error: null, |
| 21 | 8 | status: "idle", |
| 22 | 9 | }); |
| 23 | 10 | |
| 24 | −const REVIEW_INITIAL_LANGUAGES = [ | |
| 25 | − "typescript", | |
| 26 | − "tsx", | |
| 27 | − "javascript", | |
| 28 | − "jsx", | |
| 29 | − "json", | |
| 30 | − "yaml", | |
| 31 | − "bash", | |
| 32 | −] as const; | |
| 33 | − | |
| 34 | −function isReviewHighlighterProviderDebugLoggingEnabled(): boolean { | |
| 35 | − return typeof __DEV__ !== "undefined" ? __DEV__ : false; | |
| 36 | −} | |
| 37 | − | |
| 38 | −function 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 | − | |
| 54 | 11 | export function ReviewHighlighterProvider(props: { readonly children: ReactNode }) { |
| 55 | − const [value, setValue] = useState<ReviewHighlighterContextValue>({ | |
| 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 | − | |
| 12 | + const value = useReviewHighlighterState(); | |
| 99 | 13 | const contextValue = useMemo(() => value, [value]); |
| 100 | 14 | |
| 101 | 15 | return ( |
| @@ -105,6 +19,6 @@ export function ReviewHighlighterProvider(props: { readonly children: ReactNode · 3 unmodified lines | ||
| 105 | 19 | ); |
| 106 | 20 | } |
| 107 | 21 | |
| 108 | −export function useReviewHighlighterStatus(): ReviewHighlighterContextValue { | |
| 22 | +export function useReviewHighlighterStatus(): ReviewHighlighterState { | |
| 109 | 23 | return useContext(ReviewHighlighterContext); |
| 110 | 24 | } |
| @@ -0,0 +1,75 @@ | ||
| 1 | +import { assert, beforeEach, it } from "vitest"; | |
| 2 | +import { AtomRegistry } from "effect/unstable/reactivity"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + createReviewHighlighterManager, | |
| 6 | + IDLE_REVIEW_HIGHLIGHTER_STATE, | |
| 7 | +} from "./reviewHighlighterState"; | |
| 8 | + | |
| 9 | +let registry = AtomRegistry.make(); | |
| 10 | + | |
| 11 | +beforeEach(() => { | |
| 12 | + registry.dispose(); | |
| 13 | + registry = AtomRegistry.make(); | |
| 14 | +}); | |
| 15 | + | |
| 16 | +function flushAsyncWork(): Promise<void> { | |
| 17 | + return Promise.resolve().then(() => undefined); | |
| 18 | +} | |
| 19 | + | |
| 20 | +it("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 | + | |
| 55 | +it("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 | +}); | |
| @@ -0,0 +1,154 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | +import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | |
| 3 | +import { useEffect } from "react"; | |
| 4 | + | |
| 5 | +import { appAtomRegistry } from "../../state/atom-registry"; | |
| 6 | +import { | |
| 7 | + getActiveReviewHighlighterEngine, | |
| 8 | + prepareReviewHighlighter, | |
| 9 | + prepareReviewHighlighterLanguages, | |
| 10 | + type ReviewHighlighterEngine, | |
| 11 | +} from "./shikiReviewHighlighter"; | |
| 12 | + | |
| 13 | +export type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error"; | |
| 14 | + | |
| 15 | +export interface ReviewHighlighterState { | |
| 16 | + readonly engine: ReviewHighlighterEngine | null; | |
| 17 | + readonly error: string | null; | |
| 18 | + readonly status: ReviewHighlighterStatus; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export interface ReviewHighlighterLoader { | |
| 22 | + readonly prepare: () => Promise<void>; | |
| 23 | + readonly prepareLanguages: (languages: ReadonlyArray<string>) => Promise<void>; | |
| 24 | + readonly getEngine: () => Promise<ReviewHighlighterEngine>; | |
| 25 | +} | |
| 26 | + | |
| 27 | +const REVIEW_INITIAL_LANGUAGES = [ | |
| 28 | + "typescript", | |
| 29 | + "tsx", | |
| 30 | + "javascript", | |
| 31 | + "jsx", | |
| 32 | + "json", | |
| 33 | + "yaml", | |
| 34 | + "bash", | |
| 35 | +] as const; | |
| 36 | + | |
| 37 | +export const IDLE_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({ | |
| 38 | + engine: null, | |
| 39 | + error: null, | |
| 40 | + status: "idle", | |
| 41 | +}); | |
| 42 | + | |
| 43 | +const INITIALIZING_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({ | |
| 44 | + engine: null, | |
| 45 | + error: null, | |
| 46 | + status: "initializing", | |
| 47 | +}); | |
| 48 | + | |
| 49 | +export const reviewHighlighterStateAtom = Atom.make(IDLE_REVIEW_HIGHLIGHTER_STATE).pipe( | |
| 50 | + Atom.keepAlive, | |
| 51 | + Atom.withLabel("mobile:review-highlighter"), | |
| 52 | +); | |
| 53 | + | |
| 54 | +function isReviewHighlighterProviderDebugLoggingEnabled(): boolean { | |
| 55 | + return typeof __DEV__ !== "undefined" ? __DEV__ : false; | |
| 56 | +} | |
| 57 | + | |
| 58 | +function 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 | + | |
| 74 | +export 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 | + | |
| 139 | +const reviewHighlighterManager = createReviewHighlighterManager({ | |
| 140 | + getRegistry: () => appAtomRegistry, | |
| 141 | + loader: { | |
| 142 | + prepare: prepareReviewHighlighter, | |
| 143 | + prepareLanguages: prepareReviewHighlighterLanguages, | |
| 144 | + getEngine: getActiveReviewHighlighterEngine, | |
| 145 | + }, | |
| 146 | +}); | |
| 147 | + | |
| 148 | +export function useReviewHighlighterState(): ReviewHighlighterState { | |
| 149 | + useEffect(() => { | |
| 150 | + void reviewHighlighterManager.initialize(); | |
| 151 | + }, []); | |
| 152 | + | |
| 153 | + return useAtomValue(reviewHighlighterStateAtom); | |
| 154 | +} | |
| @@ -1,4 +1,5 @@ | ||
| 1 | −import { parsePatchFiles, type ChangeTypes, type FileDiffMetadata } from "@pierre/diffs/utils"; | |
| 1 | +import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; | |
| 2 | +import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; | |
| 2 | 3 | import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts"; |
| 3 | 4 | import * as Arr from "effect/Array"; |
| 4 | 5 | import * as Order from "effect/Order"; |
| @@ -0,0 +1,32 @@ | ||
| 1 | +import { assert, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + getReviewAsyncStateSnapshot, | |
| 5 | + setReviewAsyncError, | |
| 6 | + setReviewGitDiffsLoading, | |
| 7 | + setReviewTurnDiffLoading, | |
| 8 | +} from "./reviewState"; | |
| 9 | + | |
| 10 | +it("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 | +}); | |
| @@ -9,6 +9,12 @@ import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<ReviewDiffPreviewSource>>([]); |
| 11 | 11 | const EMPTY_REVIEW_TURN_DIFFS = Object.freeze<Readonly<Record<string, string>>>({}); |
| 12 | +const EMPTY_REVIEW_LOADING_TURN_IDS = Object.freeze<Readonly<Record<string, boolean>>>({}); | |
| 13 | +const EMPTY_REVIEW_ASYNC_STATE = Object.freeze<ReviewAsyncState>({ | |
| 14 | + loadingGitDiffs: false, | |
| 15 | + loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS, | |
| 16 | + error: null, | |
| 17 | +}); | |
| 12 | 18 | const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze< |
| 13 | 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 | 30 | Atom.keepAlive, |
| 25 | 31 | Atom.withLabel("mobile:review:selected-section-id:null"), |
| 26 | 32 | ); |
| 33 | +const EMPTY_REVIEW_ASYNC_STATE_ATOM = Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( | |
| 34 | + Atom.keepAlive, | |
| 35 | + Atom.withLabel("mobile:review:async-state:null"), | |
| 36 | +); | |
| 27 | 37 | const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( |
| 28 | 38 | Atom.keepAlive, |
| 29 | 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 | |
| 63 | +const 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 | + | |
| 53 | 70 | const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => |
| 54 | 71 | Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( |
| 55 | 72 | Atom.keepAlive, |
| @@ -83,6 +100,7 @@ export interface ReviewCacheForThread { · 27 unmodified lines | ||
| 83 | 100 | readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>; |
| 84 | 101 | readonly turnDiffById: Readonly<Record<string, string>>; |
| 85 | 102 | readonly selectedSectionId: string | null; |
| 103 | + readonly asyncState: ReviewAsyncState; | |
| 86 | 104 | readonly expandedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>; |
| 87 | 105 | readonly revealedLargeFileIdsBySection: Readonly< |
| 88 | 106 | Record<string, ReadonlyArray<string> | undefined> |
| @@ -90,6 +108,12 @@ export interface ReviewCacheForThread { · 1 unmodified line | ||
| 90 | 108 | readonly viewedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>; |
| 91 | 109 | } |
| 92 | 110 | |
| 111 | +export interface ReviewAsyncState { | |
| 112 | + readonly loadingGitDiffs: boolean; | |
| 113 | + readonly loadingTurnIds: Readonly<Record<string, boolean>>; | |
| 114 | + readonly error: string | null; | |
| 115 | +} | |
| 116 | + | |
| 93 | 117 | function buildThreadKey(input: { |
| 94 | 118 | readonly environmentId?: EnvironmentId; |
| 95 | 119 | readonly threadId?: ThreadId; |
| @@ -119,6 +143,9 @@ export function useReviewCacheForThread(input: { · 23 unmodified lines | ||
| 119 | 143 | ? reviewSelectedSectionIdByThreadKeyAtom(threadKey) |
| 120 | 144 | : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM, |
| 121 | 145 | ); |
| 146 | + const asyncState = useAtomValue( | |
| 147 | + threadKey ? reviewAsyncStateByThreadKeyAtom(threadKey) : EMPTY_REVIEW_ASYNC_STATE_ATOM, | |
| 148 | + ); | |
| 122 | 149 | const expandedFileIdsBySection = useAtomValue( |
| 123 | 150 | threadKey |
| 124 | 151 | ? reviewExpandedFileIdsByThreadKeyAtom(threadKey) |
| @@ -138,6 +165,7 @@ export function useReviewCacheForThread(input: { · 13 unmodified lines | ||
| 138 | 165 | gitSections, |
| 139 | 166 | turnDiffById, |
| 140 | 167 | selectedSectionId, |
| 168 | + asyncState, | |
| 141 | 169 | expandedFileIdsBySection, |
| 142 | 170 | revealedLargeFileIdsBySection, |
| 143 | 171 | viewedFileIdsBySection, |
| @@ -164,6 +192,51 @@ export function setReviewSelectedSectionId(threadKey: string, sectionId: string · 20 unmodified lines | ||
| 164 | 192 | appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId); |
| 165 | 193 | } |
| 166 | 194 | |
| 195 | +function 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 | + | |
| 203 | +export function setReviewGitDiffsLoading(threadKey: string, loadingGitDiffs: boolean): void { | |
| 204 | + updateReviewAsyncState(threadKey, (current) => ({ | |
| 205 | + ...current, | |
| 206 | + loadingGitDiffs, | |
| 207 | + })); | |
| 208 | +} | |
| 209 | + | |
| 210 | +export 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 | + | |
| 229 | +export function setReviewAsyncError(threadKey: string, error: string | null): void { | |
| 230 | + updateReviewAsyncState(threadKey, (current) => ({ | |
| 231 | + ...current, | |
| 232 | + error, | |
| 233 | + })); | |
| 234 | +} | |
| 235 | + | |
| 236 | +export function getReviewAsyncStateSnapshot(threadKey: string): ReviewAsyncState { | |
| 237 | + return appAtomRegistry.get(reviewAsyncStateByThreadKeyAtom(threadKey)); | |
| 238 | +} | |
| 239 | + | |
| 167 | 240 | export function updateReviewExpandedFileIds( |
| 168 | 241 | threadKey: string, |
| 169 | 242 | sectionId: string, |
| @@ -9,7 +9,7 @@ import typescriptLanguage from "@shikijs/langs/typescript"; · 8 unmodified lines | ||
| 9 | 9 | import yamlLanguage from "@shikijs/langs/yaml"; |
| 10 | 10 | import githubDarkDefault from "@shikijs/themes/github-dark-default"; |
| 11 | 11 | import githubLightDefault from "@shikijs/themes/github-light-default"; |
| 12 | −import { getFiletypeFromFileName } from "@pierre/diffs/utils"; | |
| 12 | +import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName"; | |
| 13 | 13 | |
| 14 | 14 | import { |
| 15 | 15 | resolveReviewHighlighterEngine, |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 1 | +import { useCallback, useEffect, useMemo, useRef } from "react"; | |
| 2 | 2 | |
| 3 | 3 | import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; |
| 4 | 4 | |
| @@ -12,8 +12,11 @@ import { · 7 unmodified lines | ||
| 12 | 12 | getReviewSectionIdForCheckpoint, |
| 13 | 13 | } from "./reviewModel"; |
| 14 | 14 | import { |
| 15 | + setReviewAsyncError, | |
| 16 | + setReviewGitDiffsLoading, | |
| 15 | 17 | setReviewGitSections, |
| 16 | 18 | setReviewSelectedSectionId, |
| 19 | + setReviewTurnDiffLoading, | |
| 17 | 20 | setReviewTurnDiff, |
| 18 | 21 | type ReviewCacheForThread, |
| 19 | 22 | } from "./reviewState"; |
| @@ -26,9 +29,7 @@ export function useReviewSections(input: { · 6 unmodified lines | ||
| 26 | 29 | const { environmentId, reviewCache, threadId } = input; |
| 27 | 30 | const selectedThread = useSelectedThreadDetail(); |
| 28 | 31 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 29 | − const [loadingTurnIds, setLoadingTurnIds] = useState<Record<string, boolean>>({}); | |
| 30 | − const [loadingGitDiffs, setLoadingGitDiffs] = useState(false); | |
| 31 | − const [error, setError] = useState<string | null>(null); | |
| 32 | + const { error, loadingGitDiffs, loadingTurnIds } = reviewCache.asyncState; | |
| 32 | 33 | const turnDiffByIdRef = useRef(reviewCache.turnDiffById); |
| 33 | 34 | |
| 34 | 35 | useEffect(() => { |
| @@ -84,21 +85,32 @@ export function useReviewSections(input: { · 49 unmodified lines | ||
| 84 | 85 | |
| 85 | 86 | const client = getEnvironmentClient(environmentId); |
| 86 | 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 | 91 | return; |
| 89 | 92 | } |
| 90 | 93 | |
| 91 | − setLoadingGitDiffs(true); | |
| 92 | − setError(null); | |
| 94 | + if (reviewCache.threadKey) { | |
| 95 | + setReviewGitDiffsLoading(reviewCache.threadKey, true); | |
| 96 | + setReviewAsyncError(reviewCache.threadKey, null); | |
| 97 | + } | |
| 93 | 98 | try { |
| 94 | 99 | const result = await client.review.getDiffPreview({ cwd: selectedThreadCwd }); |
| 95 | 100 | if (reviewCache.threadKey) { |
| 96 | 101 | setReviewGitSections(reviewCache.threadKey, result.sources); |
| 97 | 102 | } |
| 98 | 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 | 110 | } finally { |
| 101 | − setLoadingGitDiffs(false); | |
| 111 | + if (reviewCache.threadKey) { | |
| 112 | + setReviewGitDiffsLoading(reviewCache.threadKey, false); | |
| 113 | + } | |
| 102 | 114 | } |
| 103 | 115 | }, [environmentId, reviewCache.threadKey, selectedThreadCwd]); |
| 104 | 116 | |
| @@ -119,12 +131,16 @@ export function useReviewSections(input: { · 14 unmodified lines | ||
| 119 | 131 | |
| 120 | 132 | const client = getEnvironmentClient(environmentId); |
| 121 | 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 | 137 | return; |
| 124 | 138 | } |
| 125 | 139 | |
| 126 | − setLoadingTurnIds((current) => ({ ...current, [sectionId]: true })); | |
| 127 | − setError(null); | |
| 140 | + if (reviewCache.threadKey) { | |
| 141 | + setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true); | |
| 142 | + setReviewAsyncError(reviewCache.threadKey, null); | |
| 143 | + } | |
| 128 | 144 | try { |
| 129 | 145 | const result = await client.orchestration.getTurnDiff({ |
| 130 | 146 | threadId, |
| @@ -135,13 +151,16 @@ export function useReviewSections(input: { · 4 unmodified lines | ||
| 135 | 151 | setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff); |
| 136 | 152 | } |
| 137 | 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 | 160 | } finally { |
| 140 | − setLoadingTurnIds((current) => { | |
| 141 | − const next = { ...current }; | |
| 142 | − delete next[sectionId]; | |
| 143 | − return next; | |
| 144 | − }); | |
| 161 | + if (reviewCache.threadKey) { | |
| 162 | + setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false); | |
| 163 | + } | |
| 145 | 164 | } |
| 146 | 165 | }, |
| 147 | 166 | [environmentId, reviewCache.threadKey, threadId], |
| @@ -42,7 +42,7 @@ import { · 41 unmodified lines | ||
| 42 | 42 | normalizeSearchQuery, |
| 43 | 43 | scoreQueryMatch, |
| 44 | 44 | } from "@t3tools/shared/searchRanking"; |
| 45 | −import { getEnvironmentClient } from "../../state/environment-session-registry"; | |
| 45 | +import { useComposerPathSearch } from "../../state/use-composer-path-search"; | |
| 46 | 46 | import { CLAUDE_AGENT_EFFORT_OPTIONS, type ClaudeAgentEffort } from "./claudeEffortOptions"; |
| 47 | 47 | import { 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 | 210 | // ── Trigger detection ──────────────────────────────────── |
| 211 | 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 | 213 | const handleSelectionChange = useCallback( |
| 220 | 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(() => { | |
| 228 | − const trigger = detectComposerTrigger(props.draftMessage, cursorPosition); | |
| 229 | − setComposerTrigger(trigger); | |
| 230 | − }, [props.draftMessage, cursorPosition]); | |
| 231 | − | |
| 232 | − // ── File search (debounced) ────────────────────────────── | |
| 233 | − useEffect(() => { | |
| 234 | − if (pathSearchTimerRef.current) { | |
| 235 | − clearTimeout(pathSearchTimerRef.current); | |
| 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]); | |
| 221 | + const composerTrigger = useMemo<ComposerTrigger | null>( | |
| 222 | + () => detectComposerTrigger(props.draftMessage, cursorPosition), | |
| 223 | + [cursorPosition, props.draftMessage], | |
| 224 | + ); | |
| 225 | + const pathSearch = useComposerPathSearch({ | |
| 226 | + environmentId: props.environmentId, | |
| 227 | + cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, | |
| 228 | + query: composerTrigger?.kind === "path" ? composerTrigger.query : null, | |
| 229 | + }); | |
| 281 | 230 | |
| 282 | 231 | // ── Build menu items ───────────────────────────────────── |
| 283 | 232 | const selectedProviderStatus = useMemo(() => { |
| @@ -416,7 +365,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 132 unmodified lines | ||
| 416 | 365 | } |
| 417 | 366 | |
| 418 | 367 | if (composerTrigger.kind === "path") { |
| 419 | − return pathSearchResults.map((entry) => { | |
| 368 | + return pathSearch.entries.map((entry) => { | |
| 420 | 369 | const parts = entry.path.split("/"); |
| 421 | 370 | return { |
| 422 | 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 | 381 | return []; |
| 433 | − }, [composerTrigger, selectedProviderStatus, pathSearchResults]); | |
| 382 | + }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); | |
| 434 | 383 | |
| 435 | 384 | // ── Handle command selection ────────────────────────────── |
| 436 | 385 | const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; |
| @@ -680,7 +629,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer · 243 unmodified lines | ||
| 680 | 629 | <ComposerCommandPopover |
| 681 | 630 | items={composerMenuItems} |
| 682 | 631 | triggerKind={composerTrigger.kind} |
| 683 | − isLoading={pathSearchLoading} | |
| 632 | + isLoading={pathSearch.isPending} | |
| 684 | 633 | onSelect={handleCommandSelect} |
| 685 | 634 | /> |
| 686 | 635 | </View> |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 2 | 2 | import { useRouter } from "expo-router"; |
| 3 | −import { useEffect, useState } from "react"; | |
| 3 | +import { useState } from "react"; | |
| 4 | 4 | import { Pressable, ScrollView, View } from "react-native"; |
| 5 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | 6 | import { useThemeColor } from "../../../lib/useThemeColor"; |
| @@ -52,10 +52,6 @@ export function GitBranchesSheet() { · 45 unmodified lines | ||
| 52 | 52 | .map((branch) => branch.name), |
| 53 | 53 | ); |
| 54 | 54 | |
| 55 | − useEffect(() => { | |
| 56 | − void gitActions.refreshSelectedThreadBranches(); | |
| 57 | − }, [gitActions]); | |
| 58 | − | |
| 59 | 55 | return ( |
| 60 | 56 | <ScrollView |
| 61 | 57 | showsVerticalScrollIndicator={false} |
| @@ -1,46 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − type SourceControlDiscoveryClient, | |
| 3 | − type SourceControlDiscoveryTarget, | |
| 4 | − createSourceControlDiscoveryManager, | |
| 5 | −} from "@t3tools/client-runtime"; | |
| 6 | −import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts"; | |
| 7 | − | |
| 8 | −import { appAtomRegistry } from "./atom-registry"; | |
| 9 | −import { getEnvironmentClient } from "./environment-session-registry"; | |
| 10 | − | |
| 11 | −export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager<EnvironmentId>({ | |
| 12 | − getRegistry: () => appAtomRegistry, | |
| 13 | − getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null, | |
| 14 | −}); | |
| 15 | − | |
| 16 | −export function sourceControlDiscoveryTargetForEnvironment( | |
| 17 | − environmentId: EnvironmentId | null, | |
| 18 | −): SourceControlDiscoveryTarget<EnvironmentId> { | |
| 19 | − return { key: environmentId ?? null }; | |
| 20 | −} | |
| 21 | − | |
| 22 | −export 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 | − | |
| 32 | −export function invalidateSourceControlDiscoveryForEnvironment( | |
| 33 | − environmentId: EnvironmentId | null, | |
| 34 | −): void { | |
| 35 | − sourceControlDiscoveryManager.invalidate( | |
| 36 | − sourceControlDiscoveryTargetForEnvironment(environmentId), | |
| 37 | − ); | |
| 38 | −} | |
| 39 | − | |
| 40 | −export function resetSourceControlDiscoveryState(): void { | |
| 41 | − sourceControlDiscoveryManager.reset(); | |
| 42 | −} | |
| 43 | − | |
| 44 | −export function resetSourceControlDiscoveryStateForTests(): void { | |
| 45 | − resetSourceControlDiscoveryState(); | |
| 46 | −} | |
| @@ -0,0 +1,43 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | +import { | |
| 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"; | |
| 12 | +import { useEffect, useMemo } from "react"; | |
| 13 | + | |
| 14 | +import { appAtomRegistry } from "./atom-registry"; | |
| 15 | +import { | |
| 16 | + getEnvironmentClient, | |
| 17 | + subscribeEnvironmentConnections, | |
| 18 | +} from "./environment-session-registry"; | |
| 19 | + | |
| 20 | +const composerPathSearchManager = createComposerPathSearchManager({ | |
| 21 | + getRegistry: () => appAtomRegistry, | |
| 22 | + getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null, | |
| 23 | + subscribeClientChanges: subscribeEnvironmentConnections, | |
| 24 | +}); | |
| 25 | + | |
| 26 | +export 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 | +} | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | +import { useEffect, useMemo } from "react"; | |
| 2 | 3 | import { |
| 3 | 4 | type GitBranchState, |
| 4 | 5 | type GitBranchTarget, |
| @@ -10,7 +11,10 @@ import { · 5 unmodified lines | ||
| 10 | 11 | } from "@t3tools/client-runtime"; |
| 11 | 12 | |
| 12 | 13 | import { appAtomRegistry } from "./atom-registry"; |
| 13 | −import { getEnvironmentClient } from "./environment-session-registry"; | |
| 14 | +import { | |
| 15 | + getEnvironmentClient, | |
| 16 | + subscribeEnvironmentConnections, | |
| 17 | +} from "./environment-session-registry"; | |
| 14 | 18 | |
| 15 | 19 | export const gitBranchManager = createGitBranchManager({ |
| 16 | 20 | getRegistry: () => appAtomRegistry, |
| @@ -18,10 +22,21 @@ export const gitBranchManager = createGitBranchManager({ · 1 unmodified line | ||
| 18 | 22 | const client = getEnvironmentClient(environmentId); |
| 19 | 23 | return client ? client.git : null; |
| 20 | 24 | }, |
| 25 | + subscribeClientChanges: subscribeEnvironmentConnections, | |
| 26 | + watchLimit: 100, | |
| 21 | 27 | }); |
| 22 | 28 | |
| 23 | 29 | export 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 | 40 | const state = useAtomValue( |
| 26 | 41 | targetKey !== null ? gitBranchStateAtom(targetKey) : EMPTY_GIT_BRANCH_ATOM, |
| 27 | 42 | ); |
| @@ -31,7 +31,7 @@ import { type ConnectedEnvironmentSummary } from "./remote-runtime-types"; · 30 unmodified lines | ||
| 31 | 31 | import { |
| 32 | 32 | invalidateSourceControlDiscoveryForEnvironment, |
| 33 | 33 | resetSourceControlDiscoveryState, |
| 34 | −} from "./source-control-discovery-manager"; | |
| 34 | +} from "./use-source-control-discovery"; | |
| 35 | 35 | import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; |
| 36 | 36 | import { shellSnapshotManager } from "./use-shell-snapshot"; |
| 37 | 37 | import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session"; |
| @@ -2,44 +2,72 @@ import { useAtomValue } from "@effect/atom-react"; · 1 unmodified line | ||
| 2 | 2 | import { |
| 3 | 3 | EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, |
| 4 | 4 | EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, |
| 5 | + type SourceControlDiscoveryClient, | |
| 5 | 6 | type SourceControlDiscoveryState, |
| 7 | + type SourceControlDiscoveryTarget, | |
| 8 | + createSourceControlDiscoveryManager, | |
| 6 | 9 | getSourceControlDiscoveryTargetKey, |
| 7 | 10 | sourceControlDiscoveryStateAtom, |
| 8 | 11 | } from "@t3tools/client-runtime"; |
| 9 | −import type { EnvironmentId } from "@t3tools/contracts"; | |
| 10 | −import { useEffect } from "react"; | |
| 12 | +import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts"; | |
| 13 | +import { useEffect, useMemo } from "react"; | |
| 11 | 14 | |
| 15 | +import { appAtomRegistry } from "./atom-registry"; | |
| 12 | 16 | import { |
| 13 | 17 | getEnvironmentClient, |
| 14 | 18 | subscribeEnvironmentConnections, |
| 15 | 19 | } from "./environment-session-registry"; |
| 16 | −import { | |
| 17 | − refreshSourceControlDiscoveryForEnvironment, | |
| 18 | − sourceControlDiscoveryTargetForEnvironment, | |
| 19 | −} from "./source-control-discovery-manager"; | |
| 20 | 20 | |
| 21 | −export function useSourceControlDiscovery( | |
| 21 | +const sourceControlDiscoveryManager = createSourceControlDiscoveryManager<EnvironmentId>({ | |
| 22 | + getRegistry: () => appAtomRegistry, | |
| 23 | + getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null, | |
| 24 | + subscribeClientChanges: subscribeEnvironmentConnections, | |
| 25 | +}); | |
| 26 | + | |
| 27 | +function sourceControlDiscoveryTargetForEnvironment( | |
| 22 | 28 | environmentId: EnvironmentId | null, |
| 23 | −): SourceControlDiscoveryState { | |
| 24 | − useEffect(() => { | |
| 25 | − if (!environmentId) { | |
| 26 | − return; | |
| 27 | − } | |
| 29 | +): SourceControlDiscoveryTarget<EnvironmentId> { | |
| 30 | + return { key: environmentId ?? null }; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export 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 | + | |
| 43 | +export function invalidateSourceControlDiscoveryForEnvironment( | |
| 44 | + environmentId: EnvironmentId | null, | |
| 45 | +): void { | |
| 46 | + sourceControlDiscoveryManager.invalidate( | |
| 47 | + sourceControlDiscoveryTargetForEnvironment(environmentId), | |
| 48 | + ); | |
| 49 | +} | |
| 28 | 50 | |
| 29 | − const refresh = () => { | |
| 30 | − const client = getEnvironmentClient(environmentId); | |
| 31 | − if (!client) { | |
| 32 | − return; | |
| 33 | − } | |
| 51 | +export function resetSourceControlDiscoveryState(): void { | |
| 52 | + sourceControlDiscoveryManager.reset(); | |
| 53 | +} | |
| 34 | 54 | |
| 35 | − void refreshSourceControlDiscoveryForEnvironment(environmentId, client.server); | |
| 36 | − }; | |
| 55 | +export function resetSourceControlDiscoveryStateForTests(): void { | |
| 56 | + resetSourceControlDiscoveryState(); | |
| 57 | +} | |
| 37 | 58 | |
| 38 | − refresh(); | |
| 39 | − return subscribeEnvironmentConnections(refresh); | |
| 40 | − }, [environmentId]); | |
| 59 | +export function useSourceControlDiscovery( | |
| 60 | + environmentId: EnvironmentId | null, | |
| 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 | 71 | const targetKey = getSourceControlDiscoveryTargetKey(target); |
| 44 | 72 | const state = useAtomValue( |
| 45 | 73 | targetKey !== null |
| @@ -39,7 +39,7 @@ function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean { · 38 unmodified lines | ||
| 39 | 39 | ); |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | −export const threadDetailManager = createThreadDetailManager({ | |
| 42 | +const threadDetailManager = createThreadDetailManager({ | |
| 43 | 43 | getRegistry: () => appAtomRegistry, |
| 44 | 44 | getClient: (environmentId) => { |
| 45 | 45 | const client = getEnvironmentClient(environmentId); |
| @@ -2,7 +2,6 @@ import type { · 1 unmodified line | ||
| 2 | 2 | ApprovalRequestId, |
| 3 | 3 | EnvironmentId, |
| 4 | 4 | ModelSelection, |
| 5 | − ProjectEntry, | |
| 6 | 5 | ProviderApprovalDecision, |
| 7 | 6 | ProviderInteractionMode, |
| 8 | 7 | ResolvedKeybindingsConfig, |
| @@ -29,9 +28,6 @@ import { · 20 unmodified lines | ||
| 29 | 28 | useRef, |
| 30 | 29 | useState, |
| 31 | 30 | } from "react"; |
| 32 | −import { useQuery } from "@tanstack/react-query"; | |
| 33 | −import { useDebouncedValue } from "@tanstack/react-pacer"; | |
| 34 | −import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | |
| 35 | 31 | import { |
| 36 | 32 | clampCollapsedComposerCursor, |
| 37 | 33 | type ComposerTrigger, |
| @@ -55,6 +51,7 @@ import { · 17 unmodified lines | ||
| 55 | 51 | insertInlineTerminalContextPlaceholder, |
| 56 | 52 | removeInlineTerminalContextPlaceholder, |
| 57 | 53 | } from "../../lib/terminalContext"; |
| 54 | +import { useComposerPathSearch } from "../../lib/composerPathSearchState"; | |
| 58 | 55 | import { |
| 59 | 56 | shouldUseCompactComposerPrimaryActions, |
| 60 | 57 | shouldUseCompactComposerFooter, |
| @@ -136,8 +133,6 @@ const runtimeModeConfig: Record< · 75 unmodified lines | ||
| 136 | 133 | }; |
| 137 | 134 | |
| 138 | 135 | const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; |
| 139 | −const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; | |
| 140 | −const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | |
| 141 | 136 | const COMPOSER_FLOATING_LAYER_SELECTOR = [ |
| 142 | 137 | '[data-slot="popover-popup"]', |
| 143 | 138 | '[data-slot="menu-popup"]', |
| @@ -833,27 +828,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 689 unmodified lines | ||
| 833 | 828 | const composerTriggerKind = composerTrigger?.kind ?? null; |
| 834 | 829 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| 835 | 830 | const isPathTrigger = composerTriggerKind === "path"; |
| 836 | − const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( | |
| 837 | − pathTriggerQuery, | |
| 838 | − { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, | |
| 839 | − (debouncerState) => ({ isPending: debouncerState.isPending }), | |
| 840 | − ); | |
| 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; | |
| 831 | + const workspaceEntries = useComposerPathSearch({ | |
| 832 | + environmentId, | |
| 833 | + cwd: isPathTrigger ? gitCwd : null, | |
| 834 | + query: isPathTrigger ? pathTriggerQuery : null, | |
| 835 | + }); | |
| 852 | 836 | |
| 853 | 837 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { |
| 854 | 838 | if (!composerTrigger) return []; |
| 855 | 839 | if (composerTrigger.kind === "path") { |
| 856 | − return workspaceEntries.map((entry) => ({ | |
| 840 | + return workspaceEntries.entries.map((entry) => ({ | |
| 857 | 841 | id: `path:${entry.kind}:${entry.path}`, |
| 858 | 842 | type: "path", |
| 859 | 843 | path: entry.path, |
| @@ -919,7 +903,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 59 unmodified lines | ||
| 919 | 903 | ); |
| 920 | 904 | } |
| 921 | 905 | return []; |
| 922 | − }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]); | |
| 906 | + }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); | |
| 923 | 907 | |
| 924 | 908 | const composerMenuOpen = Boolean(composerTrigger); |
| 925 | 909 | const composerMenuSearchKey = composerTrigger |
| @@ -984,10 +968,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 58 unmodified lines | ||
| 984 | 968 | ]); |
| 985 | 969 | |
| 986 | 970 | const isComposerMenuLoading = |
| 987 | − composerTriggerKind === "path" && | |
| 988 | − ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || | |
| 989 | − workspaceEntriesQuery.isLoading || | |
| 990 | − workspaceEntriesQuery.isFetching); | |
| 971 | + composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; | |
| 991 | 972 | const composerMenuEmptyState = useMemo(() => { |
| 992 | 973 | if (composerTriggerKind === "skill") { |
| 993 | 974 | return "No skills found. Try / to browse provider commands."; |
| @@ -0,0 +1,48 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | +import { | |
| 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"; | |
| 12 | +import { useEffect, useMemo } from "react"; | |
| 13 | + | |
| 14 | +import { | |
| 15 | + readEnvironmentConnection, | |
| 16 | + subscribeEnvironmentConnections, | |
| 17 | +} from "../environments/runtime"; | |
| 18 | +import { appAtomRegistry } from "../rpc/atomRegistry"; | |
| 19 | + | |
| 20 | +const COMPOSER_PATH_SEARCH_LIMIT = 80; | |
| 21 | +const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; | |
| 22 | + | |
| 23 | +const 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 | + | |
| 31 | +export 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 | +} | |
| @@ -35,7 +35,7 @@ function sourceControlDiscoveryTarget( · 34 unmodified lines | ||
| 35 | 35 | : { key: environmentId }; |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | −export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ | |
| 38 | +const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ | |
| 39 | 39 | getRegistry: () => appAtomRegistry, |
| 40 | 40 | getClient: (key) => { |
| 41 | 41 | if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { |
| @@ -339,7 +339,10 @@ · 338 unmodified lines | ||
| 339 | 339 | "node-pty", |
| 340 | 340 | ], |
| 341 | 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 | 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 | 347 | "overrides": { |
| 345 | 348 | "@effect/atom-react": "catalog:", |
| @@ -92,7 +92,10 @@ · 91 unmodified lines | ||
| 92 | 92 | ] |
| 93 | 93 | }, |
| 94 | 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 | 100 | "trustedDependencies": [ |
| 98 | 101 | "node-pty", |
| @@ -0,0 +1,111 @@ | ||
| 1 | +import { assert, beforeEach, it } from "vitest"; | |
| 2 | +import type { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | +import { AtomRegistry } from "effect/unstable/reactivity"; | |
| 4 | + | |
| 5 | +import { | |
| 6 | + type ComposerPathSearchClient, | |
| 7 | + createComposerPathSearchManager, | |
| 8 | + EMPTY_COMPOSER_PATH_SEARCH_STATE, | |
| 9 | + getComposerPathSearchTargetKey, | |
| 10 | +} from "./composerPathSearchState.ts"; | |
| 11 | + | |
| 12 | +let registry = AtomRegistry.make(); | |
| 13 | + | |
| 14 | +const noop = () => undefined; | |
| 15 | + | |
| 16 | +beforeEach(() => { | |
| 17 | + registry.dispose(); | |
| 18 | + registry = AtomRegistry.make(); | |
| 19 | +}); | |
| 20 | + | |
| 21 | +function flushAsyncWork(): Promise<void> { | |
| 22 | + return Promise.resolve().then(() => undefined); | |
| 23 | +} | |
| 24 | + | |
| 25 | +const TARGET = { | |
| 26 | + environmentId: "env-local" as EnvironmentId, | |
| 27 | + cwd: "/repo", | |
| 28 | + query: "src", | |
| 29 | +}; | |
| 30 | + | |
| 31 | +it("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 | + | |
| 37 | +it("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 | + | |
| 65 | +it("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 | + | |
| 101 | +it("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 | +}); | |
| @@ -0,0 +1,288 @@ | ||
| 1 | +import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; | |
| 2 | +import * as Duration from "effect/Duration"; | |
| 3 | +import * as Effect from "effect/Effect"; | |
| 4 | +import * as Fiber from "effect/Fiber"; | |
| 5 | +import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | |
| 6 | + | |
| 7 | +export interface ComposerPathSearchEntry { | |
| 8 | + readonly path: string; | |
| 9 | + readonly kind: "file" | "directory"; | |
| 10 | + readonly parentPath?: string; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface ComposerPathSearchState { | |
| 14 | + readonly entries: ReadonlyArray<ComposerPathSearchEntry>; | |
| 15 | + readonly isPending: boolean; | |
| 16 | + readonly error: string | null; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export interface ComposerPathSearchTarget { | |
| 20 | + readonly environmentId: EnvironmentId | null; | |
| 21 | + readonly cwd: string | null; | |
| 22 | + readonly query: string | null; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface ComposerPathSearchClient { | |
| 26 | + readonly searchEntries: (input: { | |
| 27 | + readonly cwd: string; | |
| 28 | + readonly query: string; | |
| 29 | + readonly limit: number; | |
| 30 | + }) => Promise<ProjectSearchEntriesResult>; | |
| 31 | +} | |
| 32 | + | |
| 33 | +interface WatchedEntry { | |
| 34 | + refCount: number; | |
| 35 | + teardown: () => void; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export const EMPTY_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({ | |
| 39 | + entries: [], | |
| 40 | + isPending: false, | |
| 41 | + error: null, | |
| 42 | +}); | |
| 43 | + | |
| 44 | +const PENDING_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({ | |
| 45 | + entries: [], | |
| 46 | + isPending: true, | |
| 47 | + error: null, | |
| 48 | +}); | |
| 49 | + | |
| 50 | +const NOOP: () => void = () => undefined; | |
| 51 | +const DEFAULT_DEBOUNCE_MS = 200; | |
| 52 | +const DEFAULT_LIMIT = 20; | |
| 53 | + | |
| 54 | +export 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 | + | |
| 61 | +export 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 | + | |
| 66 | +export function normalizeComposerPathSearchQuery(query: string | null): string { | |
| 67 | + return query?.trim() ?? ""; | |
| 68 | +} | |
| 69 | + | |
| 70 | +export 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 | + | |
| 79 | +function 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 | + | |
| 89 | +export 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 | +} | |
| @@ -16,6 +16,8 @@ function resetAtomRegistry() { · 15 unmodified lines | ||
| 16 | 16 | atomRegistry = AtomRegistry.make(); |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | +const noop = () => undefined; | |
| 20 | + | |
| 19 | 21 | const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; |
| 20 | 22 | |
| 21 | 23 | const FIRST_PAGE: GitListBranchesResult = { |
| @@ -158,4 +160,35 @@ describe("createGitBranchManager", () => { · 136 unmodified lines | ||
| 158 | 160 | |
| 159 | 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 | }); |
| @@ -82,10 +82,22 @@ function mergeBranches( · 81 unmodified lines | ||
| 82 | 82 | export interface GitBranchManagerConfig { |
| 83 | 83 | readonly getRegistry: () => AtomRegistry.AtomRegistry; |
| 84 | 84 | readonly getClient: (environmentId: EnvironmentId) => GitBranchClient | null; |
| 85 | + readonly subscribeClientChanges?: (listener: () => void) => () => void; | |
| 86 | + readonly watchLimit?: number; | |
| 85 | 87 | } |
| 86 | 88 | |
| 89 | +interface WatchedEntry { | |
| 90 | + refCount: number; | |
| 91 | + teardown: () => void; | |
| 92 | +} | |
| 93 | + | |
| 94 | +const NOOP: () => void = () => undefined; | |
| 95 | + | |
| 87 | 96 | export function createGitBranchManager(config: GitBranchManagerConfig) { |
| 88 | 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 | 102 | function getSnapshot(target: GitBranchTarget): GitBranchState { |
| 91 | 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 | 283 | function invalidate(target?: GitBranchTarget): void { |
| 208 | 284 | if (target) { |
| 209 | 285 | const targetKey = getGitBranchTargetKey(target); |
| @@ -219,11 +295,16 @@ export function createGitBranchManager(config: GitBranchManagerConfig) { · 9 unmodified lines | ||
| 219 | 295 | } |
| 220 | 296 | |
| 221 | 297 | function reset(): void { |
| 298 | + for (const entry of watched.values()) { | |
| 299 | + entry.teardown(); | |
| 300 | + } | |
| 301 | + watched.clear(); | |
| 222 | 302 | invalidate(); |
| 223 | 303 | } |
| 224 | 304 | |
| 225 | 305 | return { |
| 226 | 306 | getSnapshot, |
| 307 | + watch, | |
| 227 | 308 | load, |
| 228 | 309 | loadNext, |
| 229 | 310 | invalidate, |
| @@ -19,3 +19,4 @@ export * from "./wsRpcProtocol.ts"; · 18 unmodified lines | ||
| 19 | 19 | export * from "./wsTransport.ts"; |
| 20 | 20 | export * from "./wsRpcClient.ts"; |
| 21 | 21 | export * from "./environmentConnection.ts"; |
| 22 | +export * from "./composerPathSearchState.ts"; | |
| @@ -50,11 +50,17 @@ function unresolvedDiscovery() { · 49 unmodified lines | ||
| 50 | 50 | |
| 51 | 51 | let registry = AtomRegistry.make(); |
| 52 | 52 | |
| 53 | +const noop = () => undefined; | |
| 54 | + | |
| 53 | 55 | beforeEach(() => { |
| 54 | 56 | registry.dispose(); |
| 55 | 57 | registry = AtomRegistry.make(); |
| 56 | 58 | }); |
| 57 | 59 | |
| 60 | +function flushAsyncWork(): Promise<void> { | |
| 61 | + return Promise.resolve().then(() => undefined); | |
| 62 | +} | |
| 63 | + | |
| 58 | 64 | it("stores refreshed discovery data in an atom snapshot", async () => { |
| 59 | 65 | const manager = createSourceControlDiscoveryManager({ |
| 60 | 66 | getRegistry: () => registry, |
| @@ -181,3 +187,99 @@ it("ignores an in-flight refresh after the target is invalidated", async () => { · 120 unmodified lines | ||
| 181 | 187 | isPending: true, |
| 182 | 188 | }); |
| 183 | 189 | }); |
| 190 | + | |
| 191 | +it("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 | + | |
| 237 | +it("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 | +}); | |
| @@ -17,6 +17,11 @@ export interface SourceControlDiscoveryClient { · 16 unmodified lines | ||
| 17 | 17 | readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>; |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | +interface WatchedEntry { | |
| 21 | + refCount: number; | |
| 22 | + teardown: () => void; | |
| 23 | +} | |
| 24 | + | |
| 20 | 25 | /* --- Constants ------------------------------------------------------ */ |
| 21 | 26 | |
| 22 | 27 | export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({ |
| @@ -71,13 +76,29 @@ export interface SourceControlDiscoveryManagerConfig<TKey extends string = strin · 48 unmodified lines | ||
| 71 | 76 | * clients without changing the state primitive. |
| 72 | 77 | */ |
| 73 | 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 | |
| 88 | +const NOOP: () => void = () => undefined; | |
| 89 | + | |
| 76 | 90 | export function createSourceControlDiscoveryManager<TKey extends string = string>( |
| 77 | 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 | 100 | const refreshVersions = new Map<string, number>(); |
| 101 | + const watched = new Map<string, WatchedEntry>(); | |
| 81 | 102 | |
| 82 | 103 | function getRefreshVersion(targetKey: string): number { |
| 83 | 104 | return refreshVersions.get(targetKey) ?? 0; |
| @@ -154,11 +175,6 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 70 unmodified lines | ||
| 154 | 175 | return Promise.resolve(null); |
| 155 | 176 | } |
| 156 | 177 | |
| 157 | − const existing = refreshInFlight.get(targetKey); | |
| 158 | − if (existing) { | |
| 159 | − return existing; | |
| 160 | − } | |
| 161 | − | |
| 162 | 178 | const resolvedClient = client ?? config.getClient(targetKey); |
| 163 | 179 | if (!resolvedClient) { |
| 164 | 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 | 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 | 194 | markPending(targetKey); |
| 170 | 195 | const refreshVersion = getRefreshVersion(targetKey); |
| 171 | 196 | const promise = resolvedClient.discoverSourceControl().then( |
| @@ -184,11 +209,14 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 12 unmodified lines | ||
| 184 | 209 | ); |
| 185 | 210 | let tracked: Promise<SourceControlDiscoveryResult | null>; |
| 186 | 211 | tracked = promise.finally(() => { |
| 187 | − if (refreshInFlight.get(targetKey) === tracked) { | |
| 212 | + if (refreshInFlight.get(targetKey)?.promise === tracked) { | |
| 188 | 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 | 220 | return tracked; |
| 193 | 221 | } |
| 194 | 222 | |
| @@ -228,12 +256,93 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 33 unmodified lines | ||
| 228 | 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 | 337 | * Clear in-flight refresh tracking and reset every known discovery atom. |
| 233 | 338 | * Primarily used by tests and runtime teardown. |
| 234 | 339 | */ |
| 235 | 340 | function reset(): void { |
| 236 | 341 | const keys = new Set([...knownSourceControlDiscoveryKeys, ...refreshInFlight.keys()]); |
| 342 | + for (const entry of watched.values()) { | |
| 343 | + entry.teardown(); | |
| 344 | + } | |
| 345 | + watched.clear(); | |
| 237 | 346 | refreshInFlight.clear(); |
| 238 | 347 | for (const key of keys) { |
| 239 | 348 | bumpRefreshVersion(key); |
| @@ -242,6 +351,7 @@ export function createSourceControlDiscoveryManager<TKey extends string = string · 2 unmodified lines | ||
| 242 | 351 | } |
| 243 | 352 | |
| 244 | 353 | return { |
| 354 | + watch, | |
| 245 | 355 | refresh, |
| 246 | 356 | getSnapshot, |
| 247 | 357 | invalidate, |
| @@ -0,0 +1,23 @@ | ||
| 1 | +diff --git a/package.json b/package.json | |
| 2 | +index 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": { | |
| @@ -0,0 +1,15 @@ | ||
| 1 | +diff --git a/ios/EXDevLauncherController.m b/ios/EXDevLauncherController.m | |
| 2 | +index 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]; | |
| @@ -0,0 +1,16 @@ | ||
| 1 | +diff --git a/package.json b/package.json | |
| 2 | +index 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": { | |
| @@ -40,8 +40,10 @@ const sourceExtensions = new Set([".swift", ".kt", ".kts"]); · 39 unmodified lines | ||
| 40 | 40 | const excludedDirectories = new Set([ |
| 41 | 41 | ".expo", |
| 42 | 42 | ".git", |
| 43 | + "android", | |
| 43 | 44 | "build", |
| 44 | 45 | "DerivedData", |
| 46 | + "ios", | |
| 45 | 47 | "node_modules", |
| 46 | 48 | "Pods", |
| 47 | 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"); | |
| 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 | − } | |
| 193 | + yield* Console.log("Skipping generated native project folders: android/, ios/."); | |
| 204 | 194 | }); |
| 205 | 195 | |
| 206 | 196 | export const mobileNativeStaticCheckCommand = Command.make("mobile-native-static-check", {}, () => |
| @@ -1,63 +0,0 @@ | ||
| 1 | −import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 2 | −import { join } from "node:path"; | |
| 3 | − | |
| 4 | −const root = process.cwd(); | |
| 5 | − | |
| 6 | −const candidates = [join(root, "node_modules/expo-dev-launcher/ios/EXDevLauncherController.m")]; | |
| 7 | − | |
| 8 | −const bunStorePath = join(root, "node_modules/.bun"); | |
| 9 | − | |
| 10 | −if (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 | − | |
| 22 | −const targetLine = "[manager updateCurrentBridge:self.appBridge];"; | |
| 23 | −const 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 | − | |
| 29 | −let patchedCount = 0; | |
| 30 | −let locatedCount = 0; | |
| 31 | − | |
| 32 | −for (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 | − | |
| 55 | −if (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 | −} | |
| @@ -1,105 +0,0 @@ | ||
| 1 | −import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 2 | −import { join } from "node:path"; | |
| 3 | − | |
| 4 | −const root = process.cwd(); | |
| 5 | −const bunStorePath = join(root, "node_modules/.bun"); | |
| 6 | − | |
| 7 | −const packageDirs = []; | |
| 8 | − | |
| 9 | −if (existsSync(join(root, "node_modules/@pierre/diffs"))) { | |
| 10 | − packageDirs.push(join(root, "node_modules/@pierre/diffs")); | |
| 11 | −} | |
| 12 | − | |
| 13 | −if (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 | − | |
| 23 | −const 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 | − | |
| 36 | −const 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 | − | |
| 50 | −let patchedCount = 0; | |
| 51 | −let locatedCount = 0; | |
| 52 | − | |
| 53 | −for (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 | − | |
| 99 | −if (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 | −} | |
| @@ -1,73 +0,0 @@ | ||
| 1 | −import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 2 | −import { join } from "node:path"; | |
| 3 | − | |
| 4 | −const root = process.cwd(); | |
| 5 | − | |
| 6 | −const candidates = [join(root, "node_modules/react-native-nitro-modules/package.json")]; | |
| 7 | − | |
| 8 | −const bunStorePath = join(root, "node_modules/.bun"); | |
| 9 | − | |
| 10 | −if (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 | − | |
| 22 | −let patchedCount = 0; | |
| 23 | −let locatedCount = 0; | |
| 24 | − | |
| 25 | −for (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 | − | |
| 63 | −if (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 | −} | |