| author | |
| committer | |
| log | 77b655c47b82c9b2aad09e447117b7618a16e21d |
| tree | 7cb24b456eb805bde59b3dd722b836908cf5ce6f |
| parent | d7cf8aaa8d4fbcbdd523b4f4bc86fda5c47b4a70 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 137 insertions(+), 404 deletions(-)
apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+111-114| ... | @@ -48,7 +48,6 @@ import { | ... | @@ -48,7 +48,6 @@ import { |
| 48 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | 48 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, |
| 49 | } from "../layout/native-mail-search-toolbar"; | 49 | } from "../layout/native-mail-search-toolbar"; |
| 50 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | 50 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 51 | import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; | ||
| 52 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 51 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 53 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; | 52 | import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; |
| 54 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | 53 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| ... | @@ -823,123 +822,121 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { | ... | @@ -823,123 +822,121 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { |
| 823 | : [projectName, parentDir].filter(Boolean).join(" · "); | 822 | : [projectName, parentDir].filter(Boolean).join(" · "); |
| 824 | 823 | ||
| 825 | return ( | 824 | return ( |
| 826 | <ReviewHighlighterProvider> | 825 | <View className="flex-1 bg-sheet"> |
| 827 | <View className="flex-1 bg-sheet"> | 826 | <NativeStackScreenOptions |
| 828 | <NativeStackScreenOptions | 827 | options={{ |
| 829 | options={{ | 828 | // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid |
| 830 | // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid | 829 | // sheet-colored header — this route's content scrolls internally, so |
| 831 | // sheet-colored header — this route's content scrolls internally, so | 830 | // there is nothing for glass to sample). Only dynamic values here. |
| 832 | // there is nothing for glass to sample). Only dynamic values here. | 831 | headerShown: !isAndroid, |
| 833 | headerShown: !isAndroid, | 832 | headerTintColor: iconColor, |
| 834 | headerTintColor: iconColor, | 833 | headerTitle: basename(relativePath), |
| 835 | headerTitle: basename(relativePath), | 834 | title: basename(relativePath), |
| 836 | title: basename(relativePath), | 835 | unstable_headerSubtitle: |
| 837 | unstable_headerSubtitle: | 836 | Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, |
| 838 | Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, | 837 | }} |
| 839 | }} | 838 | /> |
| 839 | {isAndroid ? ( | ||
| 840 | <AndroidScreenHeader | ||
| 841 | title={basename(relativePath)} | ||
| 842 | subtitle={headerSubtitle} | ||
| 843 | onBack={handleBack} | ||
| 844 | trailing={ | ||
| 845 | <> | ||
| 846 | {fileInspector.supported ? ( | ||
| 847 | <AndroidHeaderIconButton | ||
| 848 | accessibilityLabel={ | ||
| 849 | panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" | ||
| 850 | } | ||
| 851 | icon="sidebar.right" | ||
| 852 | onPress={toggleAuxiliaryPane} | ||
| 853 | /> | ||
| 854 | ) : null} | ||
| 855 | <ControlPillMenu | ||
| 856 | actions={androidFileMenuActions} | ||
| 857 | isAnchoredToRight | ||
| 858 | title="File actions" | ||
| 859 | onPressAction={handleAndroidFileMenuAction} | ||
| 860 | > | ||
| 861 | <AndroidHeaderIconButton accessibilityLabel="File actions" icon="ellipsis" /> | ||
| 862 | </ControlPillMenu> | ||
| 863 | </> | ||
| 864 | } | ||
| 840 | /> | 865 | /> |
| 841 | {isAndroid ? ( | 866 | ) : null} |
| 842 | <AndroidScreenHeader | 867 | <WorkspaceSidebarToolbar> |
| 843 | title={basename(relativePath)} | 868 | {fileInspector.supported ? ( |
| 844 | subtitle={headerSubtitle} | 869 | <NativeHeaderToolbar.Button |
| 845 | onBack={handleBack} | 870 | accessibilityLabel="Return to chat" |
| 846 | trailing={ | 871 | icon="chevron.left" |
| 847 | <> | 872 | onPress={handleReturnToThread} |
| 848 | {fileInspector.supported ? ( | 873 | /> |
| 849 | <AndroidHeaderIconButton | 874 | ) : null} |
| 850 | accessibilityLabel={ | 875 | </WorkspaceSidebarToolbar> |
| 851 | panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" | 876 | <NativeHeaderToolbar placement="right"> |
| 852 | } | 877 | {fileInspector.supported ? ( |
| 853 | icon="sidebar.right" | 878 | <NativeHeaderToolbar.Button |
| 854 | onPress={toggleAuxiliaryPane} | 879 | accessibilityLabel={ |
| 855 | /> | 880 | panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" |
| 856 | ) : null} | ||
| 857 | <ControlPillMenu | ||
| 858 | actions={androidFileMenuActions} | ||
| 859 | isAnchoredToRight | ||
| 860 | title="File actions" | ||
| 861 | onPressAction={handleAndroidFileMenuAction} | ||
| 862 | > | ||
| 863 | <AndroidHeaderIconButton accessibilityLabel="File actions" icon="ellipsis" /> | ||
| 864 | </ControlPillMenu> | ||
| 865 | </> | ||
| 866 | } | 881 | } |
| 882 | icon="sidebar.right" | ||
| 883 | onPress={toggleAuxiliaryPane} | ||
| 884 | separateBackground | ||
| 867 | /> | 885 | /> |
| 868 | ) : null} | 886 | ) : null} |
| 869 | <WorkspaceSidebarToolbar> | 887 | <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis"> |
| 870 | {fileInspector.supported ? ( | 888 | {fileMenuActions.some(({ inline }) => inline) ? ( |
| 871 | <NativeHeaderToolbar.Button | 889 | <NativeHeaderToolbar.Menu inline> |
| 872 | accessibilityLabel="Return to chat" | 890 | {fileMenuActions |
| 873 | icon="chevron.left" | 891 | .filter(({ inline }) => inline) |
| 874 | onPress={handleReturnToThread} | 892 | .map((action) => ( |
| 875 | /> | 893 | <NativeHeaderToolbar.MenuAction |
| 876 | ) : null} | 894 | key={action.id} |
| 877 | </WorkspaceSidebarToolbar> | 895 | icon={action.icon} |
| 878 | <NativeHeaderToolbar placement="right"> | 896 | isOn={action.id === resolvedActiveMode} |
| 879 | {fileInspector.supported ? ( | 897 | onPress={action.onPress} |
| 880 | <NativeHeaderToolbar.Button | 898 | > |
| 881 | accessibilityLabel={ | 899 | {action.title} |
| 882 | panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator" | 900 | </NativeHeaderToolbar.MenuAction> |
| 883 | } | 901 | ))} |
| 884 | icon="sidebar.right" | 902 | </NativeHeaderToolbar.Menu> |
| 885 | onPress={toggleAuxiliaryPane} | ||
| 886 | separateBackground | ||
| 887 | /> | ||
| 888 | ) : null} | 903 | ) : null} |
| 889 | <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis"> | 904 | {fileMenuActions |
| 890 | {fileMenuActions.some(({ inline }) => inline) ? ( | 905 | .filter(({ inline }) => !inline) |
| 891 | <NativeHeaderToolbar.Menu inline> | 906 | .map((action) => ( |
| 892 | {fileMenuActions | 907 | <NativeHeaderToolbar.MenuAction |
| 893 | .filter(({ inline }) => inline) | 908 | key={action.id} |
| 894 | .map((action) => ( | 909 | icon={action.icon} |
| 895 | <NativeHeaderToolbar.MenuAction | 910 | onPress={action.onPress} |
| 896 | key={action.id} | 911 | > |
| 897 | icon={action.icon} | 912 | {action.title} |
| 898 | isOn={action.id === resolvedActiveMode} | 913 | </NativeHeaderToolbar.MenuAction> |
| 899 | onPress={action.onPress} | 914 | ))} |
| 900 | > | 915 | </NativeHeaderToolbar.Menu> |
| 901 | {action.title} | 916 | </NativeHeaderToolbar> |
| 902 | </NativeHeaderToolbar.MenuAction> | 917 | <FileContent |
| 903 | ))} | 918 | key={previewKey} |
| 904 | </NativeHeaderToolbar.Menu> | 919 | activeMode={resolvedActiveMode} |
| 905 | ) : null} | 920 | cwd={cwd} |
| 906 | {fileMenuActions | 921 | environmentId={environmentId} |
| 907 | .filter(({ inline }) => !inline) | 922 | previewUri={previewUri} |
| 908 | .map((action) => ( | 923 | previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null} |
| 909 | <NativeHeaderToolbar.MenuAction | 924 | onRetryPreview={handleRetryPreview} |
| 910 | key={action.id} | 925 | videoSource={videoSource} |
| 911 | icon={action.icon} | 926 | mediaSource={mediaSource} |
| 912 | onPress={action.onPress} | 927 | resolveVideoUri={assetPreview.refresh} |
| 913 | > | 928 | fileContents={fileData?.contents ?? null} |
| 914 | {action.title} | 929 | fileError={fileQuery.error} |
| 915 | </NativeHeaderToolbar.MenuAction> | 930 | initialLine={targetLine} |
| 916 | ))} | 931 | relativePath={relativePath} |
| 917 | </NativeHeaderToolbar.Menu> | 932 | threadId={threadId} |
| 918 | </NativeHeaderToolbar> | 933 | truncated={fileData?.truncated ?? false} |
| 919 | <FileContent | 934 | onRefresh={() => fileQuery.refresh()} |
| 920 | key={previewKey} | 935 | /> |
| 921 | activeMode={resolvedActiveMode} | 936 | <FilePreviewModal |
| 922 | cwd={cwd} | 937 | source={fullScreenPreview} |
| 923 | environmentId={environmentId} | 938 | onRequestClose={() => setFullScreenPreview(null)} |
| 924 | previewUri={previewUri} | 939 | /> |
| 925 | previewFailure={assetPreview._tag === "Failure" ? assetPreview.reason : null} | 940 | </View> |
| 926 | onRetryPreview={handleRetryPreview} | ||
| 927 | videoSource={videoSource} | ||
| 928 | mediaSource={mediaSource} | ||
| 929 | resolveVideoUri={assetPreview.refresh} | ||
| 930 | fileContents={fileData?.contents ?? null} | ||
| 931 | fileError={fileQuery.error} | ||
| 932 | initialLine={targetLine} | ||
| 933 | relativePath={relativePath} | ||
| 934 | threadId={threadId} | ||
| 935 | truncated={fileData?.truncated ?? false} | ||
| 936 | onRefresh={() => fileQuery.refresh()} | ||
| 937 | /> | ||
| 938 | <FilePreviewModal | ||
| 939 | source={fullScreenPreview} | ||
| 940 | onRequestClose={() => setFullScreenPreview(null)} | ||
| 941 | /> | ||
| 942 | </View> | ||
| 943 | </ReviewHighlighterProvider> | ||
| 944 | ); | 941 | ); |
| 945 | } | 942 | } |
apps/mobile/src/features/review/ReviewHighlighterProvider.tsx deleted-20| ... | @@ -1,20 +0,0 @@ | ||
| 1 | import { createContext, type ReactNode, useMemo } from "react"; | ||
| 2 | |||
| 3 | import { type ReviewHighlighterState, useReviewHighlighterState } from "./reviewHighlighterState"; | ||
| 4 | |||
| 5 | const ReviewHighlighterContext = createContext<ReviewHighlighterState>({ | ||
| 6 | engine: null, | ||
| 7 | error: null, | ||
| 8 | status: "idle", | ||
| 9 | }); | ||
| 10 | |||
| 11 | export function ReviewHighlighterProvider(props: { readonly children: ReactNode }) { | ||
| 12 | const value = useReviewHighlighterState(); | ||
| 13 | const contextValue = useMemo(() => value, [value]); | ||
| 14 | |||
| 15 | return ( | ||
| 16 | <ReviewHighlighterContext.Provider value={contextValue}> | ||
| 17 | {props.children} | ||
| 18 | </ReviewHighlighterContext.Provider> | ||
| 19 | ); | ||
| 20 | } | ||
apps/mobile/src/features/review/reviewHighlighterState.test.ts deleted-90| ... | @@ -1,90 +0,0 @@ | ||
| 1 | import { assert, beforeEach, it } from "vite-plus/test"; | ||
| 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 cause = new Error("load failed"); | ||
| 57 | const manager = createReviewHighlighterManager({ | ||
| 58 | getRegistry: () => registry, | ||
| 59 | loader: { | ||
| 60 | prepare: async () => { | ||
| 61 | throw cause; | ||
| 62 | }, | ||
| 63 | prepareLanguages: async () => undefined, | ||
| 64 | getEngine: async () => "javascript", | ||
| 65 | }, | ||
| 66 | }); | ||
| 67 | |||
| 68 | void manager.initialize(); | ||
| 69 | await flushAsyncWork(); | ||
| 70 | |||
| 71 | const snapshot = manager.getSnapshot(); | ||
| 72 | assert.strictEqual(snapshot.engine, null); | ||
| 73 | assert.strictEqual(snapshot.status, "error"); | ||
| 74 | assert.strictEqual(snapshot.error?._tag, "ReviewHighlighterManagerError"); | ||
| 75 | assert.strictEqual(snapshot.error?.operation, "prepare"); | ||
| 76 | assert.deepStrictEqual(snapshot.error?.languages, [ | ||
| 77 | "typescript", | ||
| 78 | "tsx", | ||
| 79 | "javascript", | ||
| 80 | "jsx", | ||
| 81 | "json", | ||
| 82 | "yaml", | ||
| 83 | "bash", | ||
| 84 | ]); | ||
| 85 | assert.strictEqual(snapshot.error?.cause, cause); | ||
| 86 | assert.strictEqual( | ||
| 87 | snapshot.error?.message, | ||
| 88 | "Review highlighter operation prepare failed for languages typescript, tsx, javascript, jsx, json, yaml, bash.", | ||
| 89 | ); | ||
| 90 | }); | ||
apps/mobile/src/features/review/reviewHighlighterState.ts deleted-179| ... | @@ -1,179 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | import { useEffect } from "react"; | ||
| 5 | |||
| 6 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 7 | import { | ||
| 8 | getActiveReviewHighlighterEngine, | ||
| 9 | prepareReviewHighlighter, | ||
| 10 | prepareReviewHighlighterLanguages, | ||
| 11 | type ReviewHighlighterEngine, | ||
| 12 | } from "./shikiReviewHighlighter"; | ||
| 13 | |||
| 14 | export type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error"; | ||
| 15 | |||
| 16 | export class ReviewHighlighterManagerError extends Schema.TaggedErrorClass<ReviewHighlighterManagerError>()( | ||
| 17 | "ReviewHighlighterManagerError", | ||
| 18 | { | ||
| 19 | operation: Schema.Literals(["prepare", "prepare-languages", "resolve-engine"]), | ||
| 20 | languages: Schema.Array(Schema.String), | ||
| 21 | cause: Schema.Defect(), | ||
| 22 | }, | ||
| 23 | ) { | ||
| 24 | override get message(): string { | ||
| 25 | return `Review highlighter operation ${this.operation} failed for languages ${this.languages.join(", ")}.`; | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | export interface ReviewHighlighterState { | ||
| 30 | readonly engine: ReviewHighlighterEngine | null; | ||
| 31 | readonly error: ReviewHighlighterManagerError | null; | ||
| 32 | readonly status: ReviewHighlighterStatus; | ||
| 33 | } | ||
| 34 | |||
| 35 | export interface ReviewHighlighterLoader { | ||
| 36 | readonly prepare: () => Promise<void>; | ||
| 37 | readonly prepareLanguages: (languages: ReadonlyArray<string>) => Promise<void>; | ||
| 38 | readonly getEngine: () => Promise<ReviewHighlighterEngine>; | ||
| 39 | } | ||
| 40 | |||
| 41 | const REVIEW_INITIAL_LANGUAGES = [ | ||
| 42 | "typescript", | ||
| 43 | "tsx", | ||
| 44 | "javascript", | ||
| 45 | "jsx", | ||
| 46 | "json", | ||
| 47 | "yaml", | ||
| 48 | "bash", | ||
| 49 | ] as const; | ||
| 50 | |||
| 51 | export const IDLE_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({ | ||
| 52 | engine: null, | ||
| 53 | error: null, | ||
| 54 | status: "idle", | ||
| 55 | }); | ||
| 56 | |||
| 57 | const INITIALIZING_REVIEW_HIGHLIGHTER_STATE = Object.freeze<ReviewHighlighterState>({ | ||
| 58 | engine: null, | ||
| 59 | error: null, | ||
| 60 | status: "initializing", | ||
| 61 | }); | ||
| 62 | |||
| 63 | export const reviewHighlighterStateAtom = Atom.make(IDLE_REVIEW_HIGHLIGHTER_STATE).pipe( | ||
| 64 | Atom.keepAlive, | ||
| 65 | Atom.withLabel("mobile:review-highlighter"), | ||
| 66 | ); | ||
| 67 | |||
| 68 | function isReviewHighlighterProviderDebugLoggingEnabled(): boolean { | ||
| 69 | return typeof __DEV__ !== "undefined" ? __DEV__ : false; | ||
| 70 | } | ||
| 71 | |||
| 72 | function logReviewHighlighterProviderDiagnostic( | ||
| 73 | message: string, | ||
| 74 | details?: Record<string, unknown>, | ||
| 75 | ): void { | ||
| 76 | if (!isReviewHighlighterProviderDebugLoggingEnabled()) { | ||
| 77 | return; | ||
| 78 | } | ||
| 79 | |||
| 80 | if (details) { | ||
| 81 | console.log(`[review-highlighter-provider] ${message}`, details); | ||
| 82 | return; | ||
| 83 | } | ||
| 84 | |||
| 85 | console.log(`[review-highlighter-provider] ${message}`); | ||
| 86 | } | ||
| 87 | |||
| 88 | export function createReviewHighlighterManager(config: { | ||
| 89 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 90 | readonly loader: ReviewHighlighterLoader; | ||
| 91 | readonly languages?: ReadonlyArray<string>; | ||
| 92 | }) { | ||
| 93 | let started = false; | ||
| 94 | let inFlight: Promise<void> | null = null; | ||
| 95 | |||
| 96 | function getSnapshot(): ReviewHighlighterState { | ||
| 97 | return config.getRegistry().get(reviewHighlighterStateAtom); | ||
| 98 | } | ||
| 99 | |||
| 100 | function setState(state: ReviewHighlighterState): void { | ||
| 101 | config.getRegistry().set(reviewHighlighterStateAtom, state); | ||
| 102 | } | ||
| 103 | |||
| 104 | function initialize(): Promise<void> { | ||
| 105 | if (inFlight) { | ||
| 106 | return inFlight; | ||
| 107 | } | ||
| 108 | |||
| 109 | if (started && getSnapshot().status === "ready") { | ||
| 110 | return Promise.resolve(); | ||
| 111 | } | ||
| 112 | |||
| 113 | started = true; | ||
| 114 | setState(INITIALIZING_REVIEW_HIGHLIGHTER_STATE); | ||
| 115 | |||
| 116 | inFlight = (async () => { | ||
| 117 | const startedAt = performance.now(); | ||
| 118 | const languages = config.languages ?? REVIEW_INITIAL_LANGUAGES; | ||
| 119 | let operation: ReviewHighlighterManagerError["operation"] = "prepare"; | ||
| 120 | let engine: ReviewHighlighterEngine; | ||
| 121 | try { | ||
| 122 | await config.loader.prepare(); | ||
| 123 | operation = "prepare-languages"; | ||
| 124 | await config.loader.prepareLanguages(languages); | ||
| 125 | operation = "resolve-engine"; | ||
| 126 | engine = await config.loader.getEngine(); | ||
| 127 | } catch (cause) { | ||
| 128 | const error = new ReviewHighlighterManagerError({ | ||
| 129 | operation, | ||
| 130 | languages, | ||
| 131 | cause, | ||
| 132 | }); | ||
| 133 | logReviewHighlighterProviderDiagnostic("initialization failed", { error }); | ||
| 134 | setState({ engine: null, error, status: "error" }); | ||
| 135 | return; | ||
| 136 | } | ||
| 137 | |||
| 138 | const durationMs = Math.round(performance.now() - startedAt); | ||
| 139 | logReviewHighlighterProviderDiagnostic("initialized", { | ||
| 140 | durationMs, | ||
| 141 | engine, | ||
| 142 | }); | ||
| 143 | setState({ engine, error: null, status: "ready" }); | ||
| 144 | })().finally(() => { | ||
| 145 | inFlight = null; | ||
| 146 | }); | ||
| 147 | |||
| 148 | return inFlight; | ||
| 149 | } | ||
| 150 | |||
| 151 | function reset(): void { | ||
| 152 | started = false; | ||
| 153 | inFlight = null; | ||
| 154 | setState(IDLE_REVIEW_HIGHLIGHTER_STATE); | ||
| 155 | } | ||
| 156 | |||
| 157 | return { | ||
| 158 | getSnapshot, | ||
| 159 | initialize, | ||
| 160 | reset, | ||
| 161 | }; | ||
| 162 | } | ||
| 163 | |||
| 164 | const reviewHighlighterManager = createReviewHighlighterManager({ | ||
| 165 | getRegistry: () => appAtomRegistry, | ||
| 166 | loader: { | ||
| 167 | prepare: prepareReviewHighlighter, | ||
| 168 | prepareLanguages: prepareReviewHighlighterLanguages, | ||
| 169 | getEngine: getActiveReviewHighlighterEngine, | ||
| 170 | }, | ||
| 171 | }); | ||
| 172 | |||
| 173 | export function useReviewHighlighterState(): ReviewHighlighterState { | ||
| 174 | useEffect(() => { | ||
| 175 | void reviewHighlighterManager.initialize(); | ||
| 176 | }, []); | ||
| 177 | |||
| 178 | return useAtomValue(reviewHighlighterStateAtom); | ||
| 179 | } | ||
apps/mobile/src/features/review/shikiReviewHighlighter.test.ts+26-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import type { ReviewRenderableFile } from "./reviewModel"; | 3 | import type { ReviewRenderableFile } from "./reviewModel"; |
| 4 | import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; | 4 | import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; |
| ... | @@ -138,3 +138,28 @@ describe("highlightCodeSnippet", () => { | ... | @@ -138,3 +138,28 @@ describe("highlightCodeSnippet", () => { |
| 138 | expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); | 138 | expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); |
| 139 | }); | 139 | }); |
| 140 | }); | 140 | }); |
| 141 | |||
| 142 | describe("highlightSourceFile", () => { | ||
| 143 | it("initializes source and snippet highlighting without a warmup", async () => { | ||
| 144 | vi.resetModules(); | ||
| 145 | const highlighter = await import("./shikiReviewHighlighter"); | ||
| 146 | const source = "const answer: number = 42;"; | ||
| 147 | |||
| 148 | const highlighted = await highlighter.highlightSourceFile({ | ||
| 149 | path: "example.ts", | ||
| 150 | contents: source, | ||
| 151 | theme: "dark", | ||
| 152 | }); | ||
| 153 | |||
| 154 | expect( | ||
| 155 | highlighted | ||
| 156 | .flat() | ||
| 157 | .map((token) => token.content) | ||
| 158 | .join(""), | ||
| 159 | ).toBe(source); | ||
| 160 | expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); | ||
| 161 | expect( | ||
| 162 | await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), | ||
| 163 | ).toEqual(highlighted); | ||
| 164 | }); | ||
| 165 | }); |