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