diff --git a/apps/mobile/src/components/ConfirmDialog.types.ts b/apps/mobile/src/components/ConfirmDialog.types.ts new file mode 100644 index 000000000000..7a32787cc32d --- /dev/null +++ b/apps/mobile/src/components/ConfirmDialog.types.ts @@ -0,0 +1,18 @@ +export type ConfirmDialogRequest = { + readonly title: string; + readonly message?: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly destructive?: boolean; + readonly onConfirm: () => void; + readonly onCancel?: () => void; +}; + +export type TextInputDialogRequest = { + readonly title: string; + readonly initialValue: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly onConfirm: (value: string) => void; + readonly onCancel?: () => void; +}; diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index aa1653055b59..67e9d465c573 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -4,25 +4,9 @@ import { Platform, Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; +import type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; -export type ConfirmDialogRequest = { - readonly title: string; - readonly message?: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly destructive?: boolean; - readonly onConfirm: () => void; - readonly onCancel?: () => void; -}; - -export type TextInputDialogRequest = { - readonly title: string; - readonly initialValue: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly onConfirm: (value: string) => void; - readonly onCancel?: () => void; -}; +export type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; type DialogRequest = | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index e2bae6101b60..4f06319a6846 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -2,7 +2,7 @@ import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; const NativeControls = requireNativeModule<{ presentFile( diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index 337699e8a61e..85286dbda891 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -3,7 +3,7 @@ import { Alert, Modal, Pressable, View } from "react-native"; import ImageViewing from "react-native-image-viewing"; import { openAttachmentInViewer } from "../lib/attachmentDownload"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaImagePreview } from "./MediaImagePreview"; import { AppText as Text } from "./AppText"; diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 77dd5600bc15..0692109cacde 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -1,30 +1,13 @@ import { useIsFocused } from "@react-navigation/native"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { useEffect, useEffectEvent, useState } from "react"; import { Alert, Keyboard } from "react-native"; -import type { FileBackedComposerAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import type { MediaActionsSource } from "../lib/mediaActions"; import { useRefreshAssetUrl } from "../state/assets"; import { FilePreview } from "./FilePreview"; +import type { FilePreviewSource } from "./FilePreviewModal.types"; -export interface ResolvedFilePreviewSource { - readonly kind: "image" | "pdf" | "document"; - readonly mimeType?: string; - readonly uri: string; - readonly name?: string; - readonly sourceIdentifier?: string; - readonly srcFragment?: string; - readonly actionsSource?: MediaActionsSource; -} - -export type FilePreviewSource = Omit & - ( - | { readonly uri: string } - | { readonly attachment: FileBackedComposerAttachment } - | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } - ); +export type { FilePreviewSource, ResolvedFilePreviewSource } from "./FilePreviewModal.types"; function ResolvedFilePreview(props: { readonly source: FilePreviewSource; diff --git a/apps/mobile/src/components/FilePreviewModal.types.ts b/apps/mobile/src/components/FilePreviewModal.types.ts new file mode 100644 index 000000000000..6d125125c80f --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.types.ts @@ -0,0 +1,21 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; + +import type { FileBackedComposerAttachment } from "../lib/composerImages"; +import type { MediaActionsSource } from "../lib/mediaActions"; + +export interface ResolvedFilePreviewSource { + readonly kind: "image" | "pdf" | "document"; + readonly mimeType?: string; + readonly uri: string; + readonly name?: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} + +export type FilePreviewSource = Omit & + ( + | { readonly uri: string } + | { readonly attachment: FileBackedComposerAttachment } + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + ); diff --git a/apps/mobile/src/components/MaterialConfirmDialog.tsx b/apps/mobile/src/components/MaterialConfirmDialog.tsx index c21ad042c39c..7154c7d716d8 100644 --- a/apps/mobile/src/components/MaterialConfirmDialog.tsx +++ b/apps/mobile/src/components/MaterialConfirmDialog.tsx @@ -1,4 +1,4 @@ -import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; +import type { ConfirmDialogRequest } from "./ConfirmDialog.types"; export interface MaterialConfirmDialogProps { readonly request: Pick< diff --git a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx index ceea91b24447..e941fc52eef1 100644 --- a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx @@ -2,7 +2,7 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/ import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; /** Compose content shared by screen controls and native dialogs, inside their existing Host. */ export function MaterialSegmentedButtons( diff --git a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx index 16ec879821e1..76734e14d763 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx @@ -3,7 +3,7 @@ import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSegmentedControl.tsx b/apps/mobile/src/components/MaterialSegmentedControl.tsx index eca51d2d3f26..0935da6cd66c 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.tsx @@ -1,4 +1,4 @@ -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( _props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSwitch.android.tsx b/apps/mobile/src/components/MaterialSwitch.android.tsx index 2bc9d11130d7..f08fa78690f7 100644 --- a/apps/mobile/src/components/MaterialSwitch.android.tsx +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -1,6 +1,6 @@ import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; import { View } from "react-native"; -import type { ThemedSwitchProps } from "./ThemedSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; diff --git a/apps/mobile/src/components/MaterialSwitch.types.ts b/apps/mobile/src/components/MaterialSwitch.types.ts new file mode 100644 index 000000000000..657a2139a69e --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.types.ts @@ -0,0 +1,12 @@ +import type { SwitchProps } from "react-native"; + +export type ThemedSwitchProps = Pick< + SwitchProps, + | "accessibilityHint" + | "accessibilityLabel" + | "disabled" + | "onValueChange" + | "style" + | "testID" + | "value" +>; diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx index 03e317c296af..09bebcb28b66 100644 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useMediaActions } from "../lib/mediaActions"; import { AppText } from "./AppText"; import { SymbolView } from "./AppSymbol"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaActionsMenu } from "./MediaActionsMenu"; import { MediaSourceCaption } from "./MediaSourceCaption"; diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx index e1e61cda26df..8e4d6c3130bc 100644 --- a/apps/mobile/src/components/SegmentedControl.tsx +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -3,21 +3,9 @@ import Animated, { Easing, LinearTransition, ReduceMotion } from "react-native-r import { AppText as Text } from "./AppText"; import { cn } from "../lib/cn"; import { MaterialSegmentedControl } from "./MaterialSegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; -export interface SegmentedControlProps { - readonly options: readonly { - readonly value: Value; - readonly label: string; - readonly accessibilityLabel?: string; - }[]; - readonly selected: Value; - readonly onSelect: (value: Value) => void; - /** Compact sizing applies to the non-Material control. */ - readonly size?: "default" | "compact"; - /** "tab" for the view switcher; filters stay plain buttons. */ - readonly role?: "tab" | "button"; - readonly className?: string; -} +export type { SegmentedControlProps } from "./SegmentedControl.types"; export function SegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/SegmentedControl.types.ts b/apps/mobile/src/components/SegmentedControl.types.ts new file mode 100644 index 000000000000..921dcdb1cbde --- /dev/null +++ b/apps/mobile/src/components/SegmentedControl.types.ts @@ -0,0 +1,14 @@ +export interface SegmentedControlProps { + readonly options: readonly { + readonly value: Value; + readonly label: string; + readonly accessibilityLabel?: string; + }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; + /** Compact sizing applies to the non-Material control. */ + readonly size?: "default" | "compact"; + /** "tab" for the view switcher; filters stay plain buttons. */ + readonly role?: "tab" | "button"; + readonly className?: string; +} diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index f0cf8701e50c..d88ace54cb49 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,17 +1,9 @@ -import { Platform, Switch, type SwitchProps } from "react-native"; +import { Platform, Switch } from "react-native"; import { MaterialSwitch } from "./MaterialSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; -export type ThemedSwitchProps = Pick< - SwitchProps, - | "accessibilityHint" - | "accessibilityLabel" - | "disabled" - | "onValueChange" - | "style" - | "testID" - | "value" ->; +export type { ThemedSwitchProps } from "./MaterialSwitch.types"; export function ThemedSwitch(props: ThemedSwitchProps) { if (Platform.OS === "android") { diff --git a/apps/mobile/src/dependency-graph.test.ts b/apps/mobile/src/dependency-graph.test.ts new file mode 100644 index 000000000000..f4c953925bcc --- /dev/null +++ b/apps/mobile/src/dependency-graph.test.ts @@ -0,0 +1,288 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +/** + * Dependency-graph guards for the mobile source tree (audit #13). + * + * 1. No circular imports, checked once per platform the way Metro resolves + * modules (`..*` before `.native.*` before the + * generic file). A cycle can hide behind platform resolution: a `.tsx` + * base importing a component whose `.android.tsx` variant type-imports + * back into the base is acyclic on iOS but circular on Android. + * Dynamic `import("...")` calls are excluded from this rule on purpose: + * they are the deliberate async escape hatch (e.g. composer-draft cleanup + * reaching `lib/attachmentUpload`), and Metro resolves them after both + * modules have initialized, so they cannot create an initialization cycle. + * + * 2. Cross-layer edges are ceilinged, not yet banned. `state`, `lib`, + * `native`, and `components` still reach upward into `features` at known + * sites (the app composition root `lib/runtime.ts` legitimately wires + * feature layers). Ceilings count unique `from -> to` module pairs across + * all platforms, including dynamic imports, and may only shrink: when you + * remove one of these imports, lower the constant in the same PR. + * + * Runs in CI as part of the `Test` job (`vp run --filter '!t3' test` picks up + * the `@t3tools/mobile` package test task). + */ + +const SOURCE_ROOT = __dirname; + +/** Metro candidate order per platform (platform, then native, then generic). */ +const PLATFORM_EXTENSION_ORDER = { + android: [".android.ts", ".android.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], + ios: [".ios.ts", ".ios.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], +} as const; + +type Platform = keyof typeof PLATFORM_EXTENSION_ORDER; + +const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray; + +const isGraphFile = (filePath: string): boolean => + /\.tsx?$/.test(filePath) && + !filePath.includes(".test.") && + !filePath.includes("test-support") && + !filePath.endsWith(".d.ts"); + +/** Files Metro would not even bundle for the other platform. */ +function isRelevantForPlatform(filePath: string, platform: Platform): boolean { + const name = NodePath.basename(filePath); + if (platform === "android") { + return !name.includes(".ios."); + } + return !name.includes(".android."); +} + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of NodeFS.readdirSync(dir)) { + const filePath = NodePath.join(dir, entry); + if (NodeFS.statSync(filePath).isDirectory()) { + collectSourceFiles(filePath, out); + } else if (isGraphFile(filePath)) { + out.push(filePath); + } + } + return out; +} + +function resolveRelative( + fromFile: string, + specifier: string, + extensionOrder: ReadonlyArray, +): string | null { + if (!specifier.startsWith(".")) { + return null; + } + const base = NodePath.resolve(NodePath.dirname(fromFile), specifier); + for (const ext of extensionOrder) { + for (const candidate of [base + ext, NodePath.join(base, `index${ext}`)]) { + try { + if (NodeFS.statSync(candidate).isFile()) { + const resolved = NodePath.resolve(candidate); + return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null; + } + } catch { + // Candidate does not exist; try the next one. + } + } + } + return null; +} + +interface ParsedImport { + readonly specifier: string; + readonly isDynamic: boolean; +} + +function parseImports(source: string): ParsedImport[] { + const parsed: ParsedImport[] = []; + const staticRe = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g; + const bareRe = /(?:^|\n)\s*import\s+["']([^"']+)["']/g; + const dynamicRe = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; + for (const match of source.matchAll(staticRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(bareRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(dynamicRe)) { + parsed.push({ specifier: match[1]!, isDynamic: true }); + } + return parsed; +} + +type Layer = "state" | "lib" | "components" | "native" | "features" | "other"; + +function layerOf(relativePath: string): Layer { + const top = relativePath.split(NodePath.sep)[0]!; + if (top === "features") return "features"; + return top === "state" || top === "lib" || top === "components" || top === "native" + ? (top as Layer) + : "other"; +} + +interface PlatformGraph { + readonly platform: Platform; + readonly files: ReadonlyArray; + /** Static (type or value) import edges keyed by source file. */ + readonly staticEdges: ReadonlyMap>; + /** Unique cross-layer edges, static and dynamic, as "fromRel -> toRel". */ + readonly crossLayerEdges: ReadonlySet; +} + +function buildPlatformGraph(platform: Platform): PlatformGraph { + const extensionOrder = PLATFORM_EXTENSION_ORDER[platform]; + const files = collectSourceFiles(SOURCE_ROOT) + .filter((file) => isRelevantForPlatform(file, platform)) + .sort(); + const staticEdges = new Map(); + const crossLayerEdges = new Set(); + for (const file of files) { + const targets = new Set(); + for (const { specifier, isDynamic } of parseImports(NodeFS.readFileSync(file, "utf8"))) { + const resolved = resolveRelative(file, specifier, extensionOrder); + if (resolved === null || resolved === file) { + continue; + } + const from = NodePath.relative(SOURCE_ROOT, file); + const to = NodePath.relative(SOURCE_ROOT, resolved); + const fromLayer = layerOf(from); + const toLayer = layerOf(to); + const upward = + (fromLayer === "state" || + fromLayer === "lib" || + fromLayer === "components" || + fromLayer === "native") && + (toLayer === "features" || (fromLayer === "lib" && toLayer === "state")); + if (upward) { + crossLayerEdges.add(`${from} -> ${to}`); + } + if (!isDynamic) { + targets.add(resolved); + } + } + staticEdges.set(file, [...targets]); + } + return { platform, files, staticEdges, crossLayerEdges }; +} + +/** Tarjan strongly-connected components, iterative to bound stack depth. */ +function findCycles(graph: PlatformGraph): ReadonlyArray> { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const cycles: string[][] = []; + let nextIndex = 0; + + const enter = (node: string): void => { + index.set(node, nextIndex); + low.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + }; + + for (const root of graph.files) { + if (index.has(root)) continue; + const work: Array<[string, number]> = [[root, 0]]; + enter(root); + while (work.length > 0) { + const frame = work[work.length - 1]!; + const neighbors = graph.staticEdges.get(frame[0]) ?? []; + let advanced = false; + for (let i = frame[1]; i < neighbors.length; i += 1) { + const child = neighbors[i]!; + if (!graph.staticEdges.has(child)) continue; + if (!index.has(child)) { + work[work.length - 1] = [frame[0], i + 1]; + work.push([child, 0]); + enter(child); + advanced = true; + break; + } else if (onStack.has(child)) { + low.set(frame[0], Math.min(low.get(frame[0])!, index.get(child)!)); + } + } + if (advanced) continue; + work.pop(); + const parent = work[work.length - 1]; + if (parent) { + low.set(parent[0], Math.min(low.get(parent[0])!, low.get(frame[0])!)); + } + if (low.get(frame[0]) === index.get(frame[0])) { + const component: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + component.push(member); + } while (member !== frame[0]); + if (component.length > 1) { + cycles.push(component.sort().map((file) => NodePath.relative(SOURCE_ROOT, file))); + } + } + } + } + return cycles; +} + +const graphs = PLATFORMS.map(buildPlatformGraph); + +/** Unique upward edge pairs across every platform, sorted for stable diffs. */ +function upwardEdges(): string[] { + const union = new Set(); + for (const graph of graphs) { + for (const edge of graph.crossLayerEdges) { + union.add(edge); + } + } + return [...union].sort(); +} + +describe("mobile dependency graph", () => { + it.each(PLATFORMS)("has no circular imports under %s resolution", (platform) => { + const graph = graphs.find((candidate) => candidate.platform === platform)!; + // The graph must see real files; a resolution regression here would make + // both rules vacuously pass. + expect(graph.files.length).toBeGreaterThan(500); + expect(findCycles(graph)).toEqual([]); + }); + + it("keeps upward imports from state/lib/components/native into features at the ceiling", () => { + const edges = upwardEdges(); + const edgesFor = (from: Layer, to: Layer): string[] => + edges.filter((edge) => { + const [source, target] = edge.split(" -> "); + return layerOf(source!) === from && layerOf(target!) === to; + }); + + const ceilings: ReadonlyArray = [ + // state -> features: thread ordering reaching the thread-list model, + // the incoming-share store, the connection controller hook, the + // terminal launch context, and the pending message feed. + // (legacy-plan-mode was pure model logic and moved into state/.) + ["state", "features", 6, "state must not add imports from features"], + // lib -> features: lib/runtime.ts is the app composition root and + // legitimately wires cloud/observability features; the appearance + // helpers and terminal preferences still need untangling. + ["lib", "features", 7, "lib must not add imports from features"], + // components -> features: mostly the appearance preferences provider + // and the layout toolbar bridges. + ["components", "features", 33, "components must not add imports from features"], + // native -> features: native glue reading appearance/keyboard/review features. + ["native", "features", 8, "native must not add imports from features"], + // lib -> state: attachment/session plumbing that predates the cycle + // cleanup; each remaining edge needs a real owner-side seam. + ["lib", "state", 11, "lib must not add imports from state"], + ]; + + for (const [from, to, ceiling, message] of ceilings) { + const layerEdges = edgesFor(from, to); + expect( + layerEdges.length, + `${message}. ${layerEdges.length} edges remain:\n${layerEdges.join("\n")}`, + ).toBeLessThanOrEqual(ceiling); + } + }); +}); diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index d00cf2be4daf..818f5ab8af47 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -4,7 +4,7 @@ import { cn } from "../../lib/cn"; import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import type { ReviewRenderableLineRow } from "./reviewModel"; -import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; export const REVIEW_MONO_FONT_FAMILY = Platform.select({ ios: "ui-monospace", diff --git a/apps/mobile/src/features/review/reviewHighlightedToken.types.ts b/apps/mobile/src/features/review/reviewHighlightedToken.types.ts new file mode 100644 index 000000000000..58418a32cc7c --- /dev/null +++ b/apps/mobile/src/features/review/reviewHighlightedToken.types.ts @@ -0,0 +1,6 @@ +export interface ReviewHighlightedToken { + content: string; + readonly color: string | null; + readonly fontStyle: number | null; + readonly diffHighlight?: boolean; +} diff --git a/apps/mobile/src/features/review/reviewWordDiffs.ts b/apps/mobile/src/features/review/reviewWordDiffs.ts index 34ac9bc7a746..8dd258a296cc 100644 --- a/apps/mobile/src/features/review/reviewWordDiffs.ts +++ b/apps/mobile/src/features/review/reviewWordDiffs.ts @@ -1,6 +1,6 @@ import { diffWordsWithSpace } from "diff"; -import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; interface ReviewDiffOperation { readonly value: string; diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 3050f1f67ee8..9b69c8d552ea 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -35,12 +35,9 @@ export class ReviewHighlighterEngineInitializationError extends Schema.TaggedErr } } -export interface ReviewHighlightedToken { - content: string; - readonly color: string | null; - readonly fontStyle: number | null; - readonly diffHighlight?: boolean; -} +import type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; + +export type { ReviewHighlightedToken } from "./reviewHighlightedToken.types"; const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index f2801255b3bc..4d069bca9d05 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -89,7 +89,7 @@ import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; import { resolvePendingTaskInteractionMode, resolveProviderInteractionMode, -} from "./legacy-plan-mode"; +} from "../../state/legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { resolveNewTaskBranchWorktreePath, diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts index 61c4fb65cdc9..684049155a9e 100644 --- a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; +import { resolveLegacyPlanModeEnabled } from "../../state/legacy-plan-mode"; /** * Mobile preferences are device-local, matching the desktop client setting. diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 2c6b27864432..df68a2de3019 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -32,7 +32,7 @@ vi.mock("../state/atom-registry", () => ({ })); // The real read lease and cleanup are covered by the composer ownership suite. -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: () => () => {}, })); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index 10f4fdb7c6c7..1bce3e26774f 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -20,8 +20,8 @@ import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; import { isComposerImageAttachment, isFileBackedComposerAttachment, diff --git a/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts new file mode 100644 index 000000000000..ca9044f32dc6 --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts @@ -0,0 +1,28 @@ +import type { FileBackedComposerAttachment } from "./composerImages"; +import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; + +/** + * Preview retention for saved composer attachment copies. + * + * The durable owners of an attachment file (composer drafts, queued outbox + * messages) live in state, so this module cannot reach the ownership-cleanup + * sweep directly. Composer draft state registers the owner-side cleanup hook + * at module load; until then a release has nothing to retry, because no draft + * store has loaded yet and there is nothing to clean. + */ +type UnusedAttachmentHandler = (attachment: FileBackedComposerAttachment) => void; + +let onAttachmentUnused: UnusedAttachmentHandler | null = null; + +export function registerComposerAttachmentUnusedHandler(handler: UnusedAttachmentHandler): void { + onAttachmentUnused = handler; +} + +/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ +export function retainComposerAttachmentFileForPreview( + attachment: FileBackedComposerAttachment, +): () => void { + return retainComposerAttachmentFile(attachment.fileUri, () => { + onAttachmentUnused?.(attachment); + }); +} diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts index 3693caa41185..602d5aada56b 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.test.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ exists: vi.fn(), })); -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: mocks.retain, })); vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts index 0d90f7210bab..171fc26c0566 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -3,7 +3,7 @@ import { videoMimeType } from "@t3tools/shared/video"; import type { FileBackedComposerAttachment } from "./composerImages"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; /** Retains the draft original for preview and gives each outgoing share its own lease. */ export async function loadLocalAttachmentPreview( diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index 352367c09a15..dec31f9ce039 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; import { isFileBackedComposerAttachment } from "../lib/composerImages"; import { composerAttachmentUploadKey, @@ -20,7 +21,6 @@ import { composerDraftsAtom, ensureComposerDraftsLoaded, flushComposerDrafts, - retainComposerAttachmentFileForPreview, setComposerDraftAttachmentUpload, } from "./use-composer-drafts"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts b/apps/mobile/src/state/legacy-plan-mode.test.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.test.ts rename to apps/mobile/src/state/legacy-plan-mode.test.ts diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/state/legacy-plan-mode.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.ts rename to apps/mobile/src/state/legacy-plan-mode.ts diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3d2231fb3ac3..c53dc34e7df4 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; // Keep current writes until a compatible native baseline includes the v4 reader. const THREAD_OUTBOX_SCHEMA_VERSION = 3; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 9f5dad4d5600..fa6b2f303f70 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -179,7 +179,6 @@ import { removeComposerDraftsForEnvironment, replaceComposerDraftAttachments, resetComposerDraftsLoadState, - retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, retargetNewTaskDraft, @@ -194,6 +193,7 @@ import { undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; const DRAFT: ComposerDraft = { text: "hello", diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 734199dcef5e..de886d54fe91 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -35,8 +35,11 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import { composerAttachmentFileReferenceKey, isComposerAttachmentFileRetained, - retainComposerAttachmentFile, } from "../lib/composerAttachmentFiles"; +import { + registerComposerAttachmentUnusedHandler, + retainComposerAttachmentFileForPreview, +} from "../lib/composerAttachmentPreviewRetention"; import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; @@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup( }); } -/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ -export function retainComposerAttachmentFileForPreview( - attachment: FileBackedComposerAttachment, -): () => void { - return retainComposerAttachmentFile(attachment.fileUri, () => { - scheduleUnusedComposerAttachmentCleanup([attachment]); - }); -} +/** + * Owner-side cleanup hook for the shared preview-retention helper: releasing + * the last preview/upload lease retries the unused-file sweep. Registered here + * because this module owns the draft and outbox references the sweep reads. + */ +registerComposerAttachmentUnusedHandler((attachment) => { + scheduleUnusedComposerAttachmentCleanup([attachment]); +}); function schedulePersistComposerState(): void { if (persistTimer !== null) { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d21af7d79cc3..051fe543ea2b 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -30,7 +30,7 @@ import { uuidv4 } from "../lib/uuid"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { isModelSelectionUnavailable } from "../lib/modelOptions"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import { convertPastedImagesToAttachments, createPastedTextComposerAttachment,