| author | |
| committer | |
| log | b3e8c0334b25238e2b55868a87bd6270e234b7de |
| tree | d74543d142243271c8dd48cb6c3d5ffbe54a21fb |
| parent | e3accd6e957910649a9cca1b3280c6bbbf638690 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Yash Singh <saiansh2525@gmail.com>407 files changed, 55906 insertions(+), 7497 deletions(-)
.github/workflows/ci.yml+27| ... | @@ -82,6 +82,33 @@ jobs: | ... | @@ -82,6 +82,33 @@ jobs: |
| 82 | test -f apps/desktop/dist-electron/preload.cjs | 82 | test -f apps/desktop/dist-electron/preload.cjs |
| 83 | grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs | 83 | grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs |
| 84 | 84 | ||
| 85 | mobile_native_static_analysis: | ||
| 86 | name: Mobile Native Static Analysis | ||
| 87 | runs-on: blacksmith-12vcpu-macos-26 | ||
| 88 | timeout-minutes: 10 | ||
| 89 | steps: | ||
| 90 | - name: Checkout | ||
| 91 | uses: actions/checkout@v6 | ||
| 92 | |||
| 93 | - name: Setup Bun | ||
| 94 | uses: oven-sh/setup-bun@v2 | ||
| 95 | with: | ||
| 96 | bun-version-file: package.json | ||
| 97 | |||
| 98 | - name: Setup Node | ||
| 99 | uses: actions/setup-node@v6 | ||
| 100 | with: | ||
| 101 | node-version-file: package.json | ||
| 102 | |||
| 103 | - name: Install dependencies | ||
| 104 | run: bun install --frozen-lockfile | ||
| 105 | |||
| 106 | - name: Install mobile native static analysis tools | ||
| 107 | run: brew bundle install --file apps/mobile/Brewfile | ||
| 108 | |||
| 109 | - name: Lint mobile native sources | ||
| 110 | run: node scripts/mobile-native-static-check.ts | ||
| 111 | |||
| 85 | release_smoke: | 112 | release_smoke: |
| 86 | name: Release Smoke | 113 | name: Release Smoke |
| 87 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 114 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
.oxfmtrc.json+3| ... | @@ -9,8 +9,11 @@ | ... | @@ -9,8 +9,11 @@ |
| 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", |
| 16 | "apps/mobile/uniwind-types.d.ts", | ||
| 14 | "*.icon/**" | 17 | "*.icon/**" |
| 15 | ], | 18 | ], |
| 16 | "sortPackageJson": {}, | 19 | "sortPackageJson": {}, |
.oxlintrc.json+5-1| ... | @@ -6,7 +6,10 @@ | ... | @@ -6,7 +6,10 @@ |
| 6 | "node_modules", | 6 | "node_modules", |
| 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/**", | ||
| 12 | "apps/mobile/uniwind-types.d.ts" | ||
| 10 | ], | 13 | ], |
| 11 | "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"], | 14 | "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"], |
| 12 | "jsPlugins": ["./oxlint-plugin-t3code/index.ts"], | 15 | "jsPlugins": ["./oxlint-plugin-t3code/index.ts"], |
| ... | @@ -16,6 +19,7 @@ | ... | @@ -16,6 +19,7 @@ |
| 16 | "perf": "warn" | 19 | "perf": "warn" |
| 17 | }, | 20 | }, |
| 18 | "rules": { | 21 | "rules": { |
| 22 | "unicorn/no-array-sort": "off", | ||
| 19 | "react-in-jsx-scope": "off", | 23 | "react-in-jsx-scope": "off", |
| 20 | "eslint/no-shadow": "off", | 24 | "eslint/no-shadow": "off", |
| 21 | "eslint/no-await-in-loop": "off", | 25 | "eslint/no-await-in-loop": "off", |
AGENTS.md+1| ... | @@ -3,6 +3,7 @@ | ... | @@ -3,6 +3,7 @@ |
| 3 | ## Task Completion Requirements | 3 | ## Task Completion Requirements |
| 4 | 4 | ||
| 5 | - All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed. | 5 | - All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed. |
| 6 | - If changing native mobile code, `bun lint:mobile` must also pass. | ||
| 6 | - NEVER run `bun test`. Always use `bun run test` (runs Vitest). | 7 | - NEVER run `bun test`. Always use `bun run test` (runs Vitest). |
| 7 | 8 | ||
| 8 | ## Project Snapshot | 9 | ## Project Snapshot |
apps/mobile/.editorconfig created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | root = false | ||
| 2 | |||
| 3 | [*.{kt,kts}] | ||
| 4 | indent_size = 2 | ||
| 5 | ktlint_code_style = android_studio | ||
| 6 | ktlint_standard_blank-line-between-when-conditions = disabled | ||
| 7 | ktlint_standard_class-signature = disabled | ||
| 8 | ktlint_standard_function-signature = disabled | ||
| 9 | ktlint_standard_import-ordering = disabled | ||
| 10 | ktlint_standard_multiline-expression-wrapping = disabled | ||
| 11 | ktlint_standard_trailing-comma-on-call-site = disabled | ||
| 12 | ktlint_standard_when-entry-bracing = disabled | ||
apps/mobile/.gitignore created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files | ||
| 2 | |||
| 3 | # dependencies | ||
| 4 | node_modules/ | ||
| 5 | |||
| 6 | # Expo | ||
| 7 | .expo/ | ||
| 8 | dist/ | ||
| 9 | web-build/ | ||
| 10 | expo-env.d.ts | ||
| 11 | |||
| 12 | # Native | ||
| 13 | .kotlin/ | ||
| 14 | *.orig.* | ||
| 15 | *.jks | ||
| 16 | *.p8 | ||
| 17 | *.p12 | ||
| 18 | *.key | ||
| 19 | *.mobileprovision | ||
| 20 | |||
| 21 | # Metro | ||
| 22 | .metro-health-check* | ||
| 23 | |||
| 24 | # debug | ||
| 25 | npm-debug.* | ||
| 26 | yarn-debug.* | ||
| 27 | yarn-error.* | ||
| 28 | |||
| 29 | # macOS | ||
| 30 | .DS_Store | ||
| 31 | *.pem | ||
| 32 | |||
| 33 | # local env files | ||
| 34 | .env*.local | ||
| 35 | |||
| 36 | # typescript | ||
| 37 | *.tsbuildinfo | ||
| 38 | |||
| 39 | # generated native folders | ||
| 40 | /ios | ||
| 41 | /android | ||
apps/mobile/.swiftlint.yml created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | included: | ||
| 2 | - ios/T3Code | ||
| 3 | - modules/t3-terminal/ios | ||
| 4 | - modules/t3-review-diff/ios | ||
| 5 | |||
| 6 | excluded: | ||
| 7 | - ios/Pods | ||
| 8 | - ios/build | ||
| 9 | - modules/t3-terminal/Vendor | ||
| 10 | |||
| 11 | reporter: xcode | ||
| 12 | |||
| 13 | disabled_rules: | ||
| 14 | - file_length | ||
| 15 | - function_body_length | ||
| 16 | - identifier_name | ||
| 17 | - line_length | ||
| 18 | - lower_acl_than_parent | ||
| 19 | - modifier_order | ||
| 20 | - trailing_comma | ||
| 21 | - type_body_length | ||
| 22 | |||
| 23 | opt_in_rules: | ||
| 24 | - array_init | ||
| 25 | - closure_end_indentation | ||
| 26 | - closure_spacing | ||
| 27 | - collection_alignment | ||
| 28 | - contains_over_filter_count | ||
| 29 | - contains_over_filter_is_empty | ||
| 30 | - contains_over_first_not_nil | ||
| 31 | - contains_over_range_nil_comparison | ||
| 32 | - empty_collection_literal | ||
| 33 | - empty_count | ||
| 34 | - empty_string | ||
| 35 | - enum_case_associated_values_count | ||
| 36 | - fallthrough | ||
| 37 | - fatal_error_message | ||
| 38 | - first_where | ||
| 39 | - flatmap_over_map_reduce | ||
| 40 | - force_unwrapping | ||
| 41 | - implicitly_unwrapped_optional | ||
| 42 | - last_where | ||
| 43 | - legacy_multiple | ||
| 44 | - legacy_random | ||
| 45 | - operator_usage_whitespace | ||
| 46 | - overridden_super_call | ||
| 47 | - prefer_self_type_over_type_of_self | ||
| 48 | - private_action | ||
| 49 | - private_outlet | ||
| 50 | - prohibited_super_call | ||
| 51 | - reduce_into | ||
| 52 | - redundant_nil_coalescing | ||
| 53 | - sorted_first_last | ||
| 54 | - static_operator | ||
| 55 | - toggle_bool | ||
| 56 | - unneeded_parentheses_in_closure_argument | ||
| 57 | - vertical_parameter_alignment_on_call | ||
| 58 | - yoda_condition | ||
apps/mobile/Brewfile created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | brew "swiftlint" | ||
| 2 | brew "ktlint" | ||
| 3 | brew "detekt" | ||
apps/mobile/README.md created+81| ... | @@ -0,0 +1,81 @@ | ||
| 1 | # T3 Code Mobile | ||
| 2 | |||
| 3 | > [!WARNING] | ||
| 4 | > T3 Code Mobile is currently in development and is not distributed yet. If you want to try it out, you can build it from source. | ||
| 5 | |||
| 6 | ## Quickstart | ||
| 7 | |||
| 8 | > [!NOTE] | ||
| 9 | > Uses native modules so using Expo Go is not supported. You need to use the Expo Dev Client. | ||
| 10 | |||
| 11 | This app has three variants: | ||
| 12 | |||
| 13 | - `development`: Expo dev client, installable side-by-side as `T3 Code Dev` | ||
| 14 | - `preview`: persistent internal preview build, installable side-by-side as `T3 Code Preview` | ||
| 15 | - `production`: store/release build as `T3 Code` | ||
| 16 | |||
| 17 | Run commands from `apps/mobile`. | ||
| 18 | |||
| 19 | ## Development | ||
| 20 | |||
| 21 | Start Metro for the dev client: | ||
| 22 | |||
| 23 | ```bash | ||
| 24 | bun run dev:client | ||
| 25 | ``` | ||
| 26 | |||
| 27 | Build and run the local iOS dev client: | ||
| 28 | |||
| 29 | ```bash | ||
| 30 | bun run ios:dev | ||
| 31 | ``` | ||
| 32 | |||
| 33 | Build and run the local iOS preview app: | ||
| 34 | |||
| 35 | ```bash | ||
| 36 | bun run ios:preview | ||
| 37 | ``` | ||
| 38 | |||
| 39 | Force the review diff highlighter engine: | ||
| 40 | |||
| 41 | ```bash | ||
| 42 | EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE=javascript bun run ios:dev | ||
| 43 | ``` | ||
| 44 | |||
| 45 | `javascript` is the default and recommended setting for the review diff screen. Set `EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE=native` only when you explicitly want to test the native Shiki engine. | ||
| 46 | |||
| 47 | Inspect the resolved Expo config for a variant: | ||
| 48 | |||
| 49 | ```bash | ||
| 50 | bun run config:dev | ||
| 51 | bun run config:preview | ||
| 52 | ``` | ||
| 53 | |||
| 54 | Run static checks for mobile native code: | ||
| 55 | |||
| 56 | ```bash | ||
| 57 | node ../../scripts/mobile-native-static-check.ts | ||
| 58 | ``` | ||
| 59 | |||
| 60 | The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. Missing native tools are reported as warnings and skipped locally. CI installs the default toolset from `apps/mobile/Brewfile` before running the native checks. | ||
| 61 | |||
| 62 | ## EAS Builds | ||
| 63 | |||
| 64 | Create a cloud dev-client build: | ||
| 65 | |||
| 66 | ```bash | ||
| 67 | bun run eas:ios:dev | ||
| 68 | ``` | ||
| 69 | |||
| 70 | Create a persistent preview build: | ||
| 71 | |||
| 72 | ```bash | ||
| 73 | bun run eas:ios:preview | ||
| 74 | ``` | ||
| 75 | |||
| 76 | Android equivalents: | ||
| 77 | |||
| 78 | ```bash | ||
| 79 | bun run eas:android:dev | ||
| 80 | bun run eas:android:preview | ||
| 81 | ``` | ||
apps/mobile/app.config.ts created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import type { ExpoConfig } from "expo/config"; | ||
| 2 | |||
| 3 | type AppVariant = "development" | "preview" | "production"; | ||
| 4 | |||
| 5 | const APP_VARIANT = resolveAppVariant(process.env.APP_VARIANT); | ||
| 6 | |||
| 7 | const VARIANT_CONFIG: Record< | ||
| 8 | AppVariant, | ||
| 9 | { | ||
| 10 | readonly appName: string; | ||
| 11 | readonly scheme: string; | ||
| 12 | readonly iosIcon: string; | ||
| 13 | readonly iosBundleIdentifier: string; | ||
| 14 | readonly androidPackage: string; | ||
| 15 | } | ||
| 16 | > = { | ||
| 17 | development: { | ||
| 18 | appName: "T3 Code Dev", | ||
| 19 | scheme: "t3code-dev", | ||
| 20 | iosIcon: "./assets/icon-composer-dev.icon", | ||
| 21 | iosBundleIdentifier: "com.t3tools.t3code.dev", | ||
| 22 | androidPackage: "com.t3tools.t3code.dev", | ||
| 23 | }, | ||
| 24 | preview: { | ||
| 25 | appName: "T3 Code Preview", | ||
| 26 | scheme: "t3code-preview", | ||
| 27 | iosIcon: "./assets/icon-composer-prod.icon", | ||
| 28 | iosBundleIdentifier: "com.t3tools.t3code.preview", | ||
| 29 | androidPackage: "com.t3tools.t3code.preview", | ||
| 30 | }, | ||
| 31 | production: { | ||
| 32 | appName: "T3 Code", | ||
| 33 | scheme: "t3code", | ||
| 34 | iosIcon: "./assets/icon-composer-prod.icon", | ||
| 35 | iosBundleIdentifier: "com.t3tools.t3code", | ||
| 36 | androidPackage: "com.t3tools.t3code", | ||
| 37 | }, | ||
| 38 | }; | ||
| 39 | |||
| 40 | function resolveAppVariant(value: string | undefined): AppVariant { | ||
| 41 | switch (value) { | ||
| 42 | case "development": | ||
| 43 | case "preview": | ||
| 44 | case "production": | ||
| 45 | return value; | ||
| 46 | default: | ||
| 47 | return "production"; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | const variant = VARIANT_CONFIG[APP_VARIANT]; | ||
| 52 | |||
| 53 | const config: ExpoConfig = { | ||
| 54 | name: variant.appName, | ||
| 55 | slug: "t3-code", | ||
| 56 | scheme: variant.scheme, | ||
| 57 | version: "0.1.0", | ||
| 58 | runtimeVersion: { | ||
| 59 | policy: "appVersion", | ||
| 60 | }, | ||
| 61 | orientation: "portrait", | ||
| 62 | icon: "./assets/icon.png", | ||
| 63 | userInterfaceStyle: "automatic", | ||
| 64 | updates: { | ||
| 65 | enabled: true, | ||
| 66 | url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454", | ||
| 67 | checkAutomatically: "ON_LOAD", | ||
| 68 | fallbackToCacheTimeout: 0, | ||
| 69 | }, | ||
| 70 | ios: { | ||
| 71 | icon: variant.iosIcon, | ||
| 72 | supportsTablet: true, | ||
| 73 | bundleIdentifier: variant.iosBundleIdentifier, | ||
| 74 | infoPlist: { | ||
| 75 | NSAppTransportSecurity: { | ||
| 76 | NSAllowsArbitraryLoads: true, | ||
| 77 | }, | ||
| 78 | NSLocalNetworkUsageDescription: | ||
| 79 | "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", | ||
| 80 | ITSAppUsesNonExemptEncryption: false, | ||
| 81 | }, | ||
| 82 | }, | ||
| 83 | android: { | ||
| 84 | icon: "./assets/icon.png", | ||
| 85 | package: variant.androidPackage, | ||
| 86 | adaptiveIcon: { | ||
| 87 | backgroundColor: "#E6F4FE", | ||
| 88 | foregroundImage: "./assets/android-icon-foreground.png", | ||
| 89 | backgroundImage: "./assets/android-icon-background.png", | ||
| 90 | monochromeImage: "./assets/android-icon-monochrome.png", | ||
| 91 | }, | ||
| 92 | predictiveBackGestureEnabled: false, | ||
| 93 | }, | ||
| 94 | web: { | ||
| 95 | favicon: "./assets/favicon.png", | ||
| 96 | }, | ||
| 97 | plugins: [ | ||
| 98 | [ | ||
| 99 | "expo-camera", | ||
| 100 | { | ||
| 101 | cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.", | ||
| 102 | barcodeScannerEnabled: true, | ||
| 103 | }, | ||
| 104 | ], | ||
| 105 | [ | ||
| 106 | "expo-splash-screen", | ||
| 107 | { | ||
| 108 | image: "./assets/splash-icon.png", | ||
| 109 | resizeMode: "contain", | ||
| 110 | backgroundColor: "#ffffff", | ||
| 111 | imageWidth: 220, | ||
| 112 | dark: { | ||
| 113 | image: "./assets/splash-icon.png", | ||
| 114 | backgroundColor: "#0a0a0a", | ||
| 115 | }, | ||
| 116 | }, | ||
| 117 | ], | ||
| 118 | [ | ||
| 119 | "expo-build-properties", | ||
| 120 | { | ||
| 121 | ios: { | ||
| 122 | deploymentTarget: "16.4", | ||
| 123 | }, | ||
| 124 | }, | ||
| 125 | ], | ||
| 126 | "expo-secure-store", | ||
| 127 | "expo-router", | ||
| 128 | "expo-font", | ||
| 129 | "./plugins/withAndroidCleartextTraffic.cjs", | ||
| 130 | ], | ||
| 131 | extra: { | ||
| 132 | appVariant: APP_VARIANT, | ||
| 133 | eas: { | ||
| 134 | projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", | ||
| 135 | }, | ||
| 136 | }, | ||
| 137 | owner: "pingdotgg", | ||
| 138 | }; | ||
| 139 | |||
| 140 | export default config; | ||
apps/mobile/assets.d.ts created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | declare module "*.css"; | ||
apps/mobile/assets/android-icon-background.png created| Binary files /dev/null and b/apps/mobile/assets/android-icon-background.png differ | |||
apps/mobile/assets/android-icon-foreground.png created| Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ | |||
apps/mobile/assets/android-icon-monochrome.png created| Binary files /dev/null and b/apps/mobile/assets/android-icon-monochrome.png differ | |||
apps/mobile/assets/favicon.png created| Binary files /dev/null and b/apps/mobile/assets/favicon.png differ | |||
apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | <svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||
| 2 | <path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/> | ||
| 3 | </svg> | ||
apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg created| Binary files /dev/null and b/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg differ | |||
apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png created| Binary files /dev/null and b/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png differ | |||
apps/mobile/assets/icon-composer-dev.icon/icon.json created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | { | ||
| 2 | "fill": { | ||
| 3 | "solid": "display-p3:0.00000,0.00000,0.00000,1.00000" | ||
| 4 | }, | ||
| 5 | "groups": [ | ||
| 6 | { | ||
| 7 | "layers": [ | ||
| 8 | { | ||
| 9 | "hidden": false, | ||
| 10 | "image-name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png", | ||
| 11 | "name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr", | ||
| 12 | "position": { | ||
| 13 | "scale": 1.1, | ||
| 14 | "translation-in-points": [0, 0] | ||
| 15 | } | ||
| 16 | }, | ||
| 17 | { | ||
| 18 | "image-name": "T3.svg", | ||
| 19 | "name": "T3", | ||
| 20 | "position": { | ||
| 21 | "scale": 10, | ||
| 22 | "translation-in-points": [0, 0] | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | { | ||
| 26 | "hidden": true, | ||
| 27 | "image-name": "Texturelabs_Paper_381XL.jpg", | ||
| 28 | "name": "Texturelabs_Paper_381XL" | ||
| 29 | } | ||
| 30 | ], | ||
| 31 | "shadow": { | ||
| 32 | "kind": "neutral", | ||
| 33 | "opacity": 0.5 | ||
| 34 | }, | ||
| 35 | "translucency": { | ||
| 36 | "enabled": true, | ||
| 37 | "value": 0.5 | ||
| 38 | } | ||
| 39 | } | ||
| 40 | ], | ||
| 41 | "supported-platforms": { | ||
| 42 | "circles": ["watchOS"], | ||
| 43 | "squares": "shared" | ||
| 44 | } | ||
| 45 | } | ||
apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | <svg width="77" height="47" viewBox="0 0 77 47" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||
| 2 | <path d="M14.56 45.5V8.58H1.69082e-06V-3.09944e-06H39.65V8.58H25.09V45.5H14.56ZM57.8454 46.28C54.6821 46.28 51.5404 45.8683 48.4204 45.045C45.3004 44.1783 42.6571 42.965 40.4904 41.405L44.5854 33.345C46.3188 34.6017 48.3338 35.5983 50.6304 36.335C52.9271 37.0717 55.2454 37.44 57.5854 37.44C60.2288 37.44 62.3088 36.92 63.8254 35.88C65.3421 34.84 66.1004 33.41 66.1004 31.59C66.1004 29.8567 65.4288 28.4917 64.0854 27.495C62.7421 26.4983 60.5754 26 57.5854 26H52.7754V19.045L65.4504 4.68L66.6204 8.45H42.7654V-3.09944e-06H74.6154V6.825L62.0054 21.19L56.6754 18.135H59.7304C65.3204 18.135 69.5454 19.3917 72.4054 21.905C75.2654 24.4183 76.6954 27.6467 76.6954 31.59C76.6954 34.1467 76.0238 36.5517 74.6804 38.805C73.3371 41.015 71.2788 42.8133 68.5054 44.2C65.7321 45.5867 62.1788 46.28 57.8454 46.28Z" fill="white"/> | ||
| 3 | </svg> | ||
apps/mobile/assets/icon-composer-prod.icon/icon.json created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | { | ||
| 2 | "fill": { | ||
| 3 | "solid": "display-p3:0.00000,0.00000,0.00000,1.00000" | ||
| 4 | }, | ||
| 5 | "groups": [ | ||
| 6 | { | ||
| 7 | "layers": [ | ||
| 8 | { | ||
| 9 | "image-name": "T3.svg", | ||
| 10 | "name": "T3", | ||
| 11 | "position": { | ||
| 12 | "scale": 10, | ||
| 13 | "translation-in-points": [0, 0] | ||
| 14 | } | ||
| 15 | } | ||
| 16 | ], | ||
| 17 | "shadow": { | ||
| 18 | "kind": "neutral", | ||
| 19 | "opacity": 0.5 | ||
| 20 | }, | ||
| 21 | "translucency": { | ||
| 22 | "enabled": true, | ||
| 23 | "value": 0.5 | ||
| 24 | } | ||
| 25 | } | ||
| 26 | ], | ||
| 27 | "supported-platforms": { | ||
| 28 | "circles": ["watchOS"], | ||
| 29 | "squares": "shared" | ||
| 30 | } | ||
| 31 | } | ||
apps/mobile/assets/icon.png created| Binary files /dev/null and b/apps/mobile/assets/icon.png differ | |||
apps/mobile/assets/splash-icon.png created| Binary files /dev/null and b/apps/mobile/assets/splash-icon.png differ | |||
apps/mobile/babel.config.js created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | module.exports = function (api) { | ||
| 2 | api.cache(true); | ||
| 3 | return { | ||
| 4 | presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]], | ||
| 5 | }; | ||
| 6 | }; | ||
apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz created| Binary files /dev/null and b/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz differ | |||
apps/mobile/detekt.yml created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | config: | ||
| 2 | validation: true | ||
| 3 | |||
| 4 | build: | ||
| 5 | maxIssues: 0 | ||
| 6 | |||
| 7 | complexity: | ||
| 8 | LongMethod: | ||
| 9 | active: true | ||
| 10 | threshold: 80 | ||
| 11 | TooManyFunctions: | ||
| 12 | active: false | ||
| 13 | |||
| 14 | style: | ||
| 15 | MagicNumber: | ||
| 16 | active: false | ||
| 17 | MaxLineLength: | ||
| 18 | active: true | ||
| 19 | maxLineLength: 120 | ||
apps/mobile/eas.json created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | { | ||
| 2 | "cli": { | ||
| 3 | "version": ">= 18.4.0", | ||
| 4 | "appVersionSource": "remote" | ||
| 5 | }, | ||
| 6 | "build": { | ||
| 7 | "development": { | ||
| 8 | "env": { | ||
| 9 | "APP_VARIANT": "development" | ||
| 10 | }, | ||
| 11 | "channel": "development", | ||
| 12 | "developmentClient": true, | ||
| 13 | "distribution": "internal" | ||
| 14 | }, | ||
| 15 | "preview": { | ||
| 16 | "env": { | ||
| 17 | "APP_VARIANT": "preview" | ||
| 18 | }, | ||
| 19 | "channel": "preview", | ||
| 20 | "distribution": "internal" | ||
| 21 | }, | ||
| 22 | "production": { | ||
| 23 | "env": { | ||
| 24 | "APP_VARIANT": "production" | ||
| 25 | }, | ||
| 26 | "channel": "production", | ||
| 27 | "autoIncrement": true | ||
| 28 | } | ||
| 29 | }, | ||
| 30 | "submit": { | ||
| 31 | "production": { | ||
| 32 | "ios": { | ||
| 33 | "ascAppId": "6761315631" | ||
| 34 | } | ||
| 35 | } | ||
| 36 | } | ||
| 37 | } | ||
apps/mobile/global.css created+202| ... | @@ -0,0 +1,202 @@ | ||
| 1 | @import "tailwindcss"; | ||
| 2 | @import "uniwind"; | ||
| 3 | |||
| 4 | /* ─── Theme tokens ──────────────────────────────────────────────────── */ | ||
| 5 | @layer theme { | ||
| 6 | :root { | ||
| 7 | @variant light { | ||
| 8 | /* Page backgrounds */ | ||
| 9 | --color-screen: #f2f2f7; | ||
| 10 | --color-sheet: rgba(242, 242, 247, 0.98); | ||
| 11 | |||
| 12 | /* Card / surface */ | ||
| 13 | --color-card: #ffffff; | ||
| 14 | --color-card-alt: #f5f5f5; | ||
| 15 | --color-card-translucent: rgba(255, 255, 255, 0.94); | ||
| 16 | |||
| 17 | /* Text */ | ||
| 18 | --color-foreground: #262626; | ||
| 19 | --color-foreground-secondary: #525252; | ||
| 20 | --color-foreground-muted: #737373; | ||
| 21 | --color-foreground-tertiary: #a3a3a3; | ||
| 22 | |||
| 23 | /* Borders & separators */ | ||
| 24 | --color-border: rgba(0, 0, 0, 0.08); | ||
| 25 | --color-border-subtle: rgba(0, 0, 0, 0.06); | ||
| 26 | --color-separator: rgba(0, 0, 0, 0.04); | ||
| 27 | |||
| 28 | /* Subtle backgrounds (badges, pills, overlays) */ | ||
| 29 | --color-subtle: rgba(0, 0, 0, 0.04); | ||
| 30 | --color-subtle-strong: rgba(0, 0, 0, 0.08); | ||
| 31 | |||
| 32 | /* Primary action */ | ||
| 33 | --color-primary: #262626; | ||
| 34 | --color-primary-foreground: #ffffff; | ||
| 35 | --color-primary-shadow: rgba(0, 0, 0, 0.18); | ||
| 36 | |||
| 37 | /* Secondary action */ | ||
| 38 | --color-secondary: #ffffff; | ||
| 39 | --color-secondary-foreground: #262626; | ||
| 40 | --color-secondary-border: rgba(0, 0, 0, 0.08); | ||
| 41 | |||
| 42 | /* Danger */ | ||
| 43 | --color-danger: #fef2f2; | ||
| 44 | --color-danger-border: rgba(239, 68, 68, 0.12); | ||
| 45 | --color-danger-foreground: #dc2626; | ||
| 46 | |||
| 47 | /* Inputs */ | ||
| 48 | --color-input: #ffffff; | ||
| 49 | --color-input-border: rgba(0, 0, 0, 0.1); | ||
| 50 | --color-placeholder: #a3a3a3; | ||
| 51 | |||
| 52 | /* Icons */ | ||
| 53 | --color-icon: #262626; | ||
| 54 | --color-icon-muted: #525252; | ||
| 55 | --color-icon-subtle: #a3a3a3; | ||
| 56 | |||
| 57 | /* Header / glass chrome */ | ||
| 58 | --color-header: rgba(255, 255, 255, 0.97); | ||
| 59 | --color-header-border: rgba(0, 0, 0, 0.06); | ||
| 60 | |||
| 61 | /* StatusBar */ | ||
| 62 | --color-status-bar: #f2f2f7; | ||
| 63 | |||
| 64 | /* Markdown */ | ||
| 65 | --color-md-body: #111111; | ||
| 66 | --color-md-strong: #000000; | ||
| 67 | --color-md-link: #2563eb; | ||
| 68 | --color-md-blockquote-border: rgba(0, 0, 0, 0.08); | ||
| 69 | --color-md-blockquote-bg: rgba(0, 0, 0, 0.02); | ||
| 70 | --color-md-code-bg: rgba(0, 0, 0, 0.04); | ||
| 71 | --color-md-code-text: #262626; | ||
| 72 | --color-md-user-code-bg: rgba(255, 255, 255, 0.22); | ||
| 73 | --color-md-user-code-text: #ffffff; | ||
| 74 | --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); | ||
| 75 | --color-md-user-fence-text: #ffffff; | ||
| 76 | --color-md-hr: rgba(0, 0, 0, 0.08); | ||
| 77 | |||
| 78 | /* iMessage-style user bubble */ | ||
| 79 | --color-user-bubble: #007aff; | ||
| 80 | --color-user-bubble-foreground: #ffffff; | ||
| 81 | --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78); | ||
| 82 | |||
| 83 | /* Drawer / modal backdrop */ | ||
| 84 | --color-backdrop: rgba(0, 0, 0, 0.22); | ||
| 85 | --color-drawer: rgba(255, 255, 255, 0.99); | ||
| 86 | --color-drawer-shadow: rgba(0, 0, 0, 0.12); | ||
| 87 | |||
| 88 | /* Misc */ | ||
| 89 | --color-dot-separator: rgba(0, 0, 0, 0.2); | ||
| 90 | --color-wordmark: #262626; | ||
| 91 | --color-chevron: rgba(0, 0, 0, 0.2); | ||
| 92 | } | ||
| 93 | |||
| 94 | @variant dark { | ||
| 95 | /* Page backgrounds */ | ||
| 96 | --color-screen: #0a0a0a; | ||
| 97 | --color-sheet: rgba(14, 14, 14, 0.98); | ||
| 98 | |||
| 99 | /* Card / surface */ | ||
| 100 | --color-card: #171717; | ||
| 101 | --color-card-alt: #1c1c1c; | ||
| 102 | --color-card-translucent: rgba(17, 17, 17, 0.94); | ||
| 103 | |||
| 104 | /* Text */ | ||
| 105 | --color-foreground: #f5f5f5; | ||
| 106 | --color-foreground-secondary: #a3a3a3; | ||
| 107 | --color-foreground-muted: #737373; | ||
| 108 | --color-foreground-tertiary: #525252; | ||
| 109 | |||
| 110 | /* Borders & separators */ | ||
| 111 | --color-border: rgba(255, 255, 255, 0.06); | ||
| 112 | --color-border-subtle: rgba(255, 255, 255, 0.04); | ||
| 113 | --color-separator: rgba(255, 255, 255, 0.03); | ||
| 114 | |||
| 115 | /* Subtle backgrounds (badges, pills, overlays) */ | ||
| 116 | --color-subtle: rgba(255, 255, 255, 0.04); | ||
| 117 | --color-subtle-strong: rgba(255, 255, 255, 0.08); | ||
| 118 | |||
| 119 | /* Primary action */ | ||
| 120 | --color-primary: #f5f5f5; | ||
| 121 | --color-primary-foreground: #0a0a0a; | ||
| 122 | --color-primary-shadow: rgba(0, 0, 0, 0.22); | ||
| 123 | |||
| 124 | /* Secondary action */ | ||
| 125 | --color-secondary: rgba(255, 255, 255, 0.04); | ||
| 126 | --color-secondary-foreground: #f5f5f5; | ||
| 127 | --color-secondary-border: rgba(255, 255, 255, 0.06); | ||
| 128 | |||
| 129 | /* Danger */ | ||
| 130 | --color-danger: rgba(239, 68, 68, 0.14); | ||
| 131 | --color-danger-border: rgba(248, 113, 113, 0.18); | ||
| 132 | --color-danger-foreground: #fca5a5; | ||
| 133 | |||
| 134 | /* Inputs */ | ||
| 135 | --color-input: #141414; | ||
| 136 | --color-input-border: rgba(255, 255, 255, 0.08); | ||
| 137 | --color-placeholder: #737373; | ||
| 138 | |||
| 139 | /* Icons */ | ||
| 140 | --color-icon: #f5f5f5; | ||
| 141 | --color-icon-muted: #a3a3a3; | ||
| 142 | --color-icon-subtle: #737373; | ||
| 143 | |||
| 144 | /* Header / glass chrome */ | ||
| 145 | --color-header: rgba(10, 10, 10, 0.97); | ||
| 146 | --color-header-border: rgba(255, 255, 255, 0.06); | ||
| 147 | |||
| 148 | /* StatusBar */ | ||
| 149 | --color-status-bar: #0a0a0a; | ||
| 150 | |||
| 151 | /* Markdown */ | ||
| 152 | --color-md-body: #e5e5e5; | ||
| 153 | --color-md-strong: #f5f5f5; | ||
| 154 | --color-md-link: #60a5fa; | ||
| 155 | --color-md-blockquote-border: rgba(255, 255, 255, 0.1); | ||
| 156 | --color-md-blockquote-bg: rgba(255, 255, 255, 0.03); | ||
| 157 | --color-md-code-bg: rgba(255, 255, 255, 0.06); | ||
| 158 | --color-md-code-text: #e5e5e5; | ||
| 159 | --color-md-user-code-bg: rgba(255, 255, 255, 0.18); | ||
| 160 | --color-md-user-code-text: #ffffff; | ||
| 161 | --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); | ||
| 162 | --color-md-user-fence-text: #ffffff; | ||
| 163 | --color-md-hr: rgba(255, 255, 255, 0.08); | ||
| 164 | |||
| 165 | /* iMessage-style user bubble */ | ||
| 166 | --color-user-bubble: #0a84ff; | ||
| 167 | --color-user-bubble-foreground: #ffffff; | ||
| 168 | --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78); | ||
| 169 | |||
| 170 | /* Drawer / modal backdrop */ | ||
| 171 | --color-backdrop: rgba(0, 0, 0, 0.48); | ||
| 172 | --color-drawer: rgba(14, 14, 14, 0.99); | ||
| 173 | --color-drawer-shadow: rgba(0, 0, 0, 0.32); | ||
| 174 | |||
| 175 | /* Misc */ | ||
| 176 | --color-dot-separator: rgba(255, 255, 255, 0.2); | ||
| 177 | --color-wordmark: #f5f5f5; | ||
| 178 | --color-chevron: rgba(255, 255, 255, 0.2); | ||
| 179 | } | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | /* ─── Font family ───────────────────────────────────────────────────── */ | ||
| 184 | @theme { | ||
| 185 | --font-sans: "DMSans_400Regular"; | ||
| 186 | --font-medium: "DMSans_500Medium"; | ||
| 187 | --font-bold: "DMSans_700Bold"; | ||
| 188 | } | ||
| 189 | |||
| 190 | /* ─── Custom utilities ──────────────────────────────────────────────── */ | ||
| 191 | @utility font-t3-medium { | ||
| 192 | font-family: "DMSans_500Medium"; | ||
| 193 | } | ||
| 194 | |||
| 195 | @utility font-t3-bold { | ||
| 196 | font-family: "DMSans_700Bold"; | ||
| 197 | } | ||
| 198 | |||
| 199 | @utility font-t3-extrabold { | ||
| 200 | font-family: "DMSans_700Bold"; | ||
| 201 | font-weight: 800; | ||
| 202 | } | ||
apps/mobile/index.ts created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | import "react-native-gesture-handler"; | ||
| 2 | import "expo-router/entry"; | ||
apps/mobile/metro.config.js created+45| ... | @@ -0,0 +1,45 @@ | ||
| 1 | const fs = require("node:fs"); | ||
| 2 | const path = require("node:path"); | ||
| 3 | const { getDefaultConfig } = require("expo/metro-config"); | ||
| 4 | const { withUniwindConfig } = require("uniwind/metro"); | ||
| 5 | |||
| 6 | /** @type {import("expo/metro-config").MetroConfig} */ | ||
| 7 | const config = getDefaultConfig(__dirname); | ||
| 8 | const workspaceRoot = path.resolve(__dirname, "../.."); | ||
| 9 | const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); | ||
| 10 | const resolveShikiDependencyRoot = (packageName) => { | ||
| 11 | const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] }); | ||
| 12 | let currentDir = path.dirname(entryPath); | ||
| 13 | |||
| 14 | while (!fs.existsSync(path.join(currentDir, "package.json"))) { | ||
| 15 | const parentDir = path.dirname(currentDir); | ||
| 16 | if (parentDir === currentDir) { | ||
| 17 | throw new Error(`Could not resolve package root for ${packageName}`); | ||
| 18 | } | ||
| 19 | currentDir = parentDir; | ||
| 20 | } | ||
| 21 | |||
| 22 | return currentDir; | ||
| 23 | }; | ||
| 24 | |||
| 25 | config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])]; | ||
| 26 | config.resolver = { | ||
| 27 | ...config.resolver, | ||
| 28 | extraNodeModules: { | ||
| 29 | // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread | ||
| 30 | ...(config.resolver?.extraNodeModules ?? {}), | ||
| 31 | shiki: mobileShikiRoot, | ||
| 32 | "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), | ||
| 33 | "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), | ||
| 34 | "@shikijs/engine-oniguruma": resolveShikiDependencyRoot("@shikijs/engine-oniguruma"), | ||
| 35 | "@shikijs/langs": resolveShikiDependencyRoot("@shikijs/langs"), | ||
| 36 | "@shikijs/themes": resolveShikiDependencyRoot("@shikijs/themes"), | ||
| 37 | "@shikijs/types": resolveShikiDependencyRoot("@shikijs/types"), | ||
| 38 | "@shikijs/vscode-textmate": resolveShikiDependencyRoot("@shikijs/vscode-textmate"), | ||
| 39 | }, | ||
| 40 | }; | ||
| 41 | |||
| 42 | module.exports = withUniwindConfig(config, { | ||
| 43 | cssEntryFile: "./global.css", | ||
| 44 | polyfills: { rem: 14 }, | ||
| 45 | }); | ||
apps/mobile/modules/t3-review-diff/T3ReviewDiffNative.podspec created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | require 'json' | ||
| 2 | |||
| 3 | package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) | ||
| 4 | |||
| 5 | Pod::Spec.new do |s| | ||
| 6 | s.name = 'T3ReviewDiffNative' | ||
| 7 | s.version = package['version'] | ||
| 8 | s.summary = 'Native review diff debug surface for T3 Code mobile.' | ||
| 9 | s.description = 'Native iOS review diff renderer used to prototype fast mobile review scrolling.' | ||
| 10 | s.homepage = 'https://t3tools.com' | ||
| 11 | s.license = { :type => 'UNLICENSED' } | ||
| 12 | s.author = { 'T3 Tools' => 'hello@t3tools.com' } | ||
| 13 | s.platforms = { :ios => '16.1' } | ||
| 14 | s.source = { :path => '.' } | ||
| 15 | s.source_files = 'ios/**/*.{h,m,mm,swift}' | ||
| 16 | s.frameworks = 'CoreGraphics', 'UIKit' | ||
| 17 | s.swift_version = '5.9' | ||
| 18 | s.dependency 'ExpoModulesCore' | ||
| 19 | end | ||
apps/mobile/modules/t3-review-diff/expo-module.config.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "platforms": ["apple"], | ||
| 3 | "apple": { | ||
| 4 | "modules": ["T3ReviewDiffModule"], | ||
| 5 | "podspecPath": "T3ReviewDiffNative.podspec" | ||
| 6 | } | ||
| 7 | } | ||
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | import ExpoModulesCore | ||
| 2 | |||
| 3 | public class T3ReviewDiffModule: Module { | ||
| 4 | public func definition() -> ModuleDefinition { | ||
| 5 | Name("T3ReviewDiffSurface") | ||
| 6 | |||
| 7 | View(T3ReviewDiffView.self) { | ||
| 8 | Prop("rowsJson") { (view: T3ReviewDiffView, rowsJson: String) in | ||
| 9 | view.setRowsJson(rowsJson) | ||
| 10 | } | ||
| 11 | |||
| 12 | Prop("tokensJson") { (view: T3ReviewDiffView, tokensJson: String) in | ||
| 13 | view.setTokensJson(tokensJson) | ||
| 14 | } | ||
| 15 | |||
| 16 | Prop("tokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in | ||
| 17 | view.setTokensPatchJson(tokensPatchJson) | ||
| 18 | } | ||
| 19 | |||
| 20 | Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in | ||
| 21 | view.setTokensResetKey(tokensResetKey) | ||
| 22 | } | ||
| 23 | |||
| 24 | Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in | ||
| 25 | view.setCollapsedFileIdsJson(collapsedFileIdsJson) | ||
| 26 | } | ||
| 27 | |||
| 28 | Prop("viewedFileIdsJson") { (view: T3ReviewDiffView, viewedFileIdsJson: String) in | ||
| 29 | view.setViewedFileIdsJson(viewedFileIdsJson) | ||
| 30 | } | ||
| 31 | |||
| 32 | Prop("selectedRowIdsJson") { (view: T3ReviewDiffView, selectedRowIdsJson: String) in | ||
| 33 | view.setSelectedRowIdsJson(selectedRowIdsJson) | ||
| 34 | } | ||
| 35 | |||
| 36 | Prop("collapsedCommentIdsJson") { (view: T3ReviewDiffView, collapsedCommentIdsJson: String) in | ||
| 37 | view.setCollapsedCommentIdsJson(collapsedCommentIdsJson) | ||
| 38 | } | ||
| 39 | |||
| 40 | Prop("appearanceScheme") { (view: T3ReviewDiffView, appearanceScheme: String) in | ||
| 41 | view.setAppearanceScheme(appearanceScheme) | ||
| 42 | } | ||
| 43 | |||
| 44 | Prop("themeJson") { (view: T3ReviewDiffView, themeJson: String) in | ||
| 45 | view.setThemeJson(themeJson) | ||
| 46 | } | ||
| 47 | |||
| 48 | Prop("styleJson") { (view: T3ReviewDiffView, styleJson: String) in | ||
| 49 | view.setStyleJson(styleJson) | ||
| 50 | } | ||
| 51 | |||
| 52 | Prop("rowHeight") { (view: T3ReviewDiffView, rowHeight: Double) in | ||
| 53 | view.setRowHeight(CGFloat(rowHeight)) | ||
| 54 | } | ||
| 55 | |||
| 56 | Prop("contentWidth") { (view: T3ReviewDiffView, contentWidth: Double) in | ||
| 57 | view.setContentWidth(CGFloat(contentWidth)) | ||
| 58 | } | ||
| 59 | |||
| 60 | Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment") | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift created+2311| ... | @@ -0,0 +1,2311 @@ | ||
| 1 | import ExpoModulesCore | ||
| 2 | import UIKit | ||
| 3 | |||
| 4 | private struct ReviewDiffNativeRow: Decodable { | ||
| 5 | let kind: String | ||
| 6 | let id: String | ||
| 7 | let fileId: String? | ||
| 8 | let filePath: String? | ||
| 9 | let previousPath: String? | ||
| 10 | let changeType: String? | ||
| 11 | let additions: Int? | ||
| 12 | let deletions: Int? | ||
| 13 | let text: String? | ||
| 14 | let content: String? | ||
| 15 | let change: String? | ||
| 16 | let oldLineNumber: Int? | ||
| 17 | let newLineNumber: Int? | ||
| 18 | let wordDiffRanges: [ReviewDiffNativeWordDiffRange]? | ||
| 19 | let commentText: String? | ||
| 20 | let commentRangeLabel: String? | ||
| 21 | let commentSectionTitle: String? | ||
| 22 | } | ||
| 23 | |||
| 24 | private struct ReviewDiffNativeWordDiffRange: Decodable { | ||
| 25 | let start: Int | ||
| 26 | let end: Int | ||
| 27 | } | ||
| 28 | |||
| 29 | private struct ReviewDiffNativeToken: Decodable { | ||
| 30 | let content: String | ||
| 31 | let color: String? | ||
| 32 | let fontStyle: Int? | ||
| 33 | } | ||
| 34 | |||
| 35 | private struct ReviewDiffNativeTokenPatch: Decodable { | ||
| 36 | let resetKey: String? | ||
| 37 | let chunkIndex: Int? | ||
| 38 | let tokensByRowId: [String: [ReviewDiffNativeToken]]? | ||
| 39 | } | ||
| 40 | |||
| 41 | private struct ReviewDiffNativeThemePayload: Decodable { | ||
| 42 | let background: String? | ||
| 43 | let text: String? | ||
| 44 | let mutedText: String? | ||
| 45 | let headerBackground: String? | ||
| 46 | let border: String? | ||
| 47 | let hunkBackground: String? | ||
| 48 | let hunkText: String? | ||
| 49 | let addBackground: String? | ||
| 50 | let deleteBackground: String? | ||
| 51 | let addBar: String? | ||
| 52 | let deleteBar: String? | ||
| 53 | let addText: String? | ||
| 54 | let deleteText: String? | ||
| 55 | } | ||
| 56 | |||
| 57 | private struct ReviewDiffNativeTheme { | ||
| 58 | let background: UIColor | ||
| 59 | let text: UIColor | ||
| 60 | let mutedText: UIColor | ||
| 61 | let headerBackground: UIColor | ||
| 62 | let border: UIColor | ||
| 63 | let hunkBackground: UIColor | ||
| 64 | let hunkText: UIColor | ||
| 65 | let addBackground: UIColor | ||
| 66 | let deleteBackground: UIColor | ||
| 67 | let addBar: UIColor | ||
| 68 | let deleteBar: UIColor | ||
| 69 | let addText: UIColor | ||
| 70 | let deleteText: UIColor | ||
| 71 | |||
| 72 | static func resolve(_ scheme: String) -> ReviewDiffNativeTheme { | ||
| 73 | resolve(scheme, payload: nil) | ||
| 74 | } | ||
| 75 | |||
| 76 | static func resolve( | ||
| 77 | _ scheme: String, | ||
| 78 | payload: ReviewDiffNativeThemePayload? | ||
| 79 | ) -> ReviewDiffNativeTheme { | ||
| 80 | let fallback = fallback(scheme) | ||
| 81 | guard let payload else { | ||
| 82 | return fallback | ||
| 83 | } | ||
| 84 | |||
| 85 | return ReviewDiffNativeTheme( | ||
| 86 | background: UIColor(reviewDiffHex: payload.background) ?? fallback.background, | ||
| 87 | text: UIColor(reviewDiffHex: payload.text) ?? fallback.text, | ||
| 88 | mutedText: UIColor(reviewDiffHex: payload.mutedText) ?? fallback.mutedText, | ||
| 89 | headerBackground: UIColor(reviewDiffHex: payload.headerBackground) ?? fallback.headerBackground, | ||
| 90 | border: UIColor(reviewDiffHex: payload.border) ?? fallback.border, | ||
| 91 | hunkBackground: UIColor(reviewDiffHex: payload.hunkBackground) ?? fallback.hunkBackground, | ||
| 92 | hunkText: UIColor(reviewDiffHex: payload.hunkText) ?? fallback.hunkText, | ||
| 93 | addBackground: UIColor(reviewDiffHex: payload.addBackground) ?? fallback.addBackground, | ||
| 94 | deleteBackground: UIColor(reviewDiffHex: payload.deleteBackground) ?? fallback.deleteBackground, | ||
| 95 | addBar: UIColor(reviewDiffHex: payload.addBar) ?? fallback.addBar, | ||
| 96 | deleteBar: UIColor(reviewDiffHex: payload.deleteBar) ?? fallback.deleteBar, | ||
| 97 | addText: UIColor(reviewDiffHex: payload.addText) ?? fallback.addText, | ||
| 98 | deleteText: UIColor(reviewDiffHex: payload.deleteText) ?? fallback.deleteText | ||
| 99 | ) | ||
| 100 | } | ||
| 101 | |||
| 102 | private static func fallback(_ scheme: String) -> ReviewDiffNativeTheme { | ||
| 103 | if scheme == "dark" { | ||
| 104 | return ReviewDiffNativeTheme( | ||
| 105 | background: UIColor(red: 0.07, green: 0.07, blue: 0.07, alpha: 1), | ||
| 106 | text: UIColor(red: 0.90, green: 0.90, blue: 0.90, alpha: 1), | ||
| 107 | mutedText: UIColor(red: 0.52, green: 0.52, blue: 0.52, alpha: 1), | ||
| 108 | headerBackground: UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1), | ||
| 109 | border: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1), | ||
| 110 | hunkBackground: UIColor(red: 0.03, green: 0.14, blue: 0.18, alpha: 1), | ||
| 111 | hunkText: UIColor(red: 0.41, green: 0.82, blue: 1.00, alpha: 1), | ||
| 112 | addBackground: UIColor(red: 0.02, green: 0.16, blue: 0.10, alpha: 1), | ||
| 113 | deleteBackground: UIColor(red: 0.18, green: 0.05, blue: 0.08, alpha: 1), | ||
| 114 | addBar: UIColor(red: 0.02, green: 0.82, blue: 0.54, alpha: 1), | ||
| 115 | deleteBar: UIColor(red: 1.00, green: 0.36, blue: 0.50, alpha: 1), | ||
| 116 | addText: UIColor(red: 0.10, green: 0.84, blue: 0.56, alpha: 1), | ||
| 117 | deleteText: UIColor(red: 1.00, green: 0.38, blue: 0.52, alpha: 1) | ||
| 118 | ) | ||
| 119 | } | ||
| 120 | |||
| 121 | return ReviewDiffNativeTheme( | ||
| 122 | background: UIColor.white, | ||
| 123 | text: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1), | ||
| 124 | mutedText: UIColor(red: 0.47, green: 0.47, blue: 0.47, alpha: 1), | ||
| 125 | headerBackground: UIColor.white, | ||
| 126 | border: UIColor(red: 0.88, green: 0.88, blue: 0.90, alpha: 1), | ||
| 127 | hunkBackground: UIColor(red: 0.83, green: 0.92, blue: 0.98, alpha: 1), | ||
| 128 | hunkText: UIColor(red: 0.00, green: 0.45, blue: 0.74, alpha: 1), | ||
| 129 | addBackground: UIColor(red: 0.85, green: 0.94, blue: 0.92, alpha: 1), | ||
| 130 | deleteBackground: UIColor(red: 0.96, green: 0.85, blue: 0.90, alpha: 1), | ||
| 131 | addBar: UIColor(red: 0.02, green: 0.80, blue: 0.52, alpha: 1), | ||
| 132 | deleteBar: UIColor(red: 1.00, green: 0.34, blue: 0.48, alpha: 1), | ||
| 133 | addText: UIColor(red: 0.00, green: 0.56, blue: 0.34, alpha: 1), | ||
| 134 | deleteText: UIColor(red: 0.90, green: 0.10, blue: 0.24, alpha: 1) | ||
| 135 | ) | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | private struct ReviewDiffNativeStylePayload: Decodable { | ||
| 140 | let rowHeight: Double? | ||
| 141 | let contentWidth: Double? | ||
| 142 | let changeBarWidth: Double? | ||
| 143 | let gutterWidth: Double? | ||
| 144 | let codePadding: Double? | ||
| 145 | let textVerticalInset: Double? | ||
| 146 | let fileHeaderHeight: Double? | ||
| 147 | let fileHeaderHorizontalMargin: Double? | ||
| 148 | let fileHeaderVerticalMargin: Double? | ||
| 149 | let fileHeaderCornerRadius: Double? | ||
| 150 | let fileHeaderHorizontalPadding: Double? | ||
| 151 | let fileHeaderPathRightPadding: Double? | ||
| 152 | let fileHeaderCountColumnWidth: Double? | ||
| 153 | let fileHeaderCountGap: Double? | ||
| 154 | let codeFontSize: Double? | ||
| 155 | let codeFontWeight: String? | ||
| 156 | let lineNumberFontSize: Double? | ||
| 157 | let lineNumberFontWeight: String? | ||
| 158 | let hunkFontSize: Double? | ||
| 159 | let hunkFontWeight: String? | ||
| 160 | let fileHeaderFontSize: Double? | ||
| 161 | let fileHeaderFontWeight: String? | ||
| 162 | let fileHeaderMetaFontSize: Double? | ||
| 163 | let fileHeaderMetaFontWeight: String? | ||
| 164 | let fileHeaderSubtextFontSize: Double? | ||
| 165 | let fileHeaderSubtextFontWeight: String? | ||
| 166 | let fileHeaderStatusFontSize: Double? | ||
| 167 | let fileHeaderStatusFontWeight: String? | ||
| 168 | let emptyStateFontSize: Double? | ||
| 169 | let emptyStateFontWeight: String? | ||
| 170 | } | ||
| 171 | |||
| 172 | private struct ReviewDiffNativeStyle { | ||
| 173 | let rowHeight: CGFloat | ||
| 174 | let contentWidth: CGFloat | ||
| 175 | let changeBarWidth: CGFloat | ||
| 176 | let gutterWidth: CGFloat | ||
| 177 | let codePadding: CGFloat | ||
| 178 | let textVerticalInset: CGFloat | ||
| 179 | let fileHeaderHeight: CGFloat | ||
| 180 | let fileHeaderHorizontalMargin: CGFloat | ||
| 181 | let fileHeaderVerticalMargin: CGFloat | ||
| 182 | let fileHeaderCornerRadius: CGFloat | ||
| 183 | let fileHeaderHorizontalPadding: CGFloat | ||
| 184 | let fileHeaderPathRightPadding: CGFloat | ||
| 185 | let fileHeaderCountColumnWidth: CGFloat | ||
| 186 | let fileHeaderCountGap: CGFloat | ||
| 187 | let codeFontSize: CGFloat | ||
| 188 | let codeFontWeight: UIFont.Weight | ||
| 189 | let lineNumberFontSize: CGFloat | ||
| 190 | let lineNumberFontWeight: UIFont.Weight | ||
| 191 | let hunkFontSize: CGFloat | ||
| 192 | let hunkFontWeight: UIFont.Weight | ||
| 193 | let fileHeaderFontSize: CGFloat | ||
| 194 | let fileHeaderFontWeight: UIFont.Weight | ||
| 195 | let fileHeaderMetaFontSize: CGFloat | ||
| 196 | let fileHeaderMetaFontWeight: UIFont.Weight | ||
| 197 | let fileHeaderSubtextFontSize: CGFloat | ||
| 198 | let fileHeaderSubtextFontWeight: UIFont.Weight | ||
| 199 | let fileHeaderStatusFontSize: CGFloat | ||
| 200 | let fileHeaderStatusFontWeight: UIFont.Weight | ||
| 201 | let emptyStateFontSize: CGFloat | ||
| 202 | let emptyStateFontWeight: UIFont.Weight | ||
| 203 | |||
| 204 | static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle { | ||
| 205 | ReviewDiffNativeStyle( | ||
| 206 | rowHeight: metric(payload?.rowHeight, fallback: 24), | ||
| 207 | contentWidth: metric(payload?.contentWidth, fallback: 2800), | ||
| 208 | changeBarWidth: metric(payload?.changeBarWidth, fallback: 4), | ||
| 209 | gutterWidth: metric(payload?.gutterWidth, fallback: 50), | ||
| 210 | codePadding: metric(payload?.codePadding, fallback: 8), | ||
| 211 | textVerticalInset: metric(payload?.textVerticalInset, fallback: 3), | ||
| 212 | fileHeaderHeight: metric(payload?.fileHeaderHeight, fallback: 54), | ||
| 213 | fileHeaderHorizontalMargin: metric(payload?.fileHeaderHorizontalMargin, fallback: 8), | ||
| 214 | fileHeaderVerticalMargin: metric(payload?.fileHeaderVerticalMargin, fallback: 6), | ||
| 215 | fileHeaderCornerRadius: metric(payload?.fileHeaderCornerRadius, fallback: 10), | ||
| 216 | fileHeaderHorizontalPadding: metric(payload?.fileHeaderHorizontalPadding, fallback: 12), | ||
| 217 | fileHeaderPathRightPadding: metric(payload?.fileHeaderPathRightPadding, fallback: 128), | ||
| 218 | fileHeaderCountColumnWidth: metric(payload?.fileHeaderCountColumnWidth, fallback: 42), | ||
| 219 | fileHeaderCountGap: metric(payload?.fileHeaderCountGap, fallback: 6), | ||
| 220 | codeFontSize: metric(payload?.codeFontSize, fallback: 12), | ||
| 221 | codeFontWeight: fontWeight(payload?.codeFontWeight, fallback: .bold), | ||
| 222 | lineNumberFontSize: metric(payload?.lineNumberFontSize, fallback: 11), | ||
| 223 | lineNumberFontWeight: fontWeight(payload?.lineNumberFontWeight, fallback: .bold), | ||
| 224 | hunkFontSize: metric(payload?.hunkFontSize, fallback: 12), | ||
| 225 | hunkFontWeight: fontWeight(payload?.hunkFontWeight, fallback: .bold), | ||
| 226 | fileHeaderFontSize: metric(payload?.fileHeaderFontSize, fallback: 13), | ||
| 227 | fileHeaderFontWeight: fontWeight(payload?.fileHeaderFontWeight, fallback: .bold), | ||
| 228 | fileHeaderMetaFontSize: metric(payload?.fileHeaderMetaFontSize, fallback: 12), | ||
| 229 | fileHeaderMetaFontWeight: fontWeight(payload?.fileHeaderMetaFontWeight, fallback: .bold), | ||
| 230 | fileHeaderSubtextFontSize: metric(payload?.fileHeaderSubtextFontSize, fallback: 11), | ||
| 231 | fileHeaderSubtextFontWeight: fontWeight(payload?.fileHeaderSubtextFontWeight, fallback: .medium), | ||
| 232 | fileHeaderStatusFontSize: metric(payload?.fileHeaderStatusFontSize, fallback: 10), | ||
| 233 | fileHeaderStatusFontWeight: fontWeight(payload?.fileHeaderStatusFontWeight, fallback: .semibold), | ||
| 234 | emptyStateFontSize: metric(payload?.emptyStateFontSize, fallback: 13), | ||
| 235 | emptyStateFontWeight: fontWeight(payload?.emptyStateFontWeight, fallback: .semibold) | ||
| 236 | ) | ||
| 237 | } | ||
| 238 | |||
| 239 | private static func metric(_ value: Double?, fallback: CGFloat) -> CGFloat { | ||
| 240 | guard let value, value.isFinite, value > 0 else { | ||
| 241 | return fallback | ||
| 242 | } | ||
| 243 | return CGFloat(value) | ||
| 244 | } | ||
| 245 | |||
| 246 | private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight { | ||
| 247 | switch value?.lowercased() { | ||
| 248 | case "ultralight", "ultra-light": | ||
| 249 | return .ultraLight | ||
| 250 | case "thin": | ||
| 251 | return .thin | ||
| 252 | case "light": | ||
| 253 | return .light | ||
| 254 | case "regular": | ||
| 255 | return .regular | ||
| 256 | case "medium": | ||
| 257 | return .medium | ||
| 258 | case "semibold", "semi-bold": | ||
| 259 | return .semibold | ||
| 260 | case "bold": | ||
| 261 | return .bold | ||
| 262 | case "heavy": | ||
| 263 | return .heavy | ||
| 264 | case "black": | ||
| 265 | return .black | ||
| 266 | default: | ||
| 267 | return fallback | ||
| 268 | } | ||
| 269 | } | ||
| 270 | |||
| 271 | func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle { | ||
| 272 | ReviewDiffNativeStyle( | ||
| 273 | rowHeight: rowHeight ?? self.rowHeight, | ||
| 274 | contentWidth: contentWidth ?? self.contentWidth, | ||
| 275 | changeBarWidth: changeBarWidth, | ||
| 276 | gutterWidth: gutterWidth, | ||
| 277 | codePadding: codePadding, | ||
| 278 | textVerticalInset: textVerticalInset, | ||
| 279 | fileHeaderHeight: fileHeaderHeight, | ||
| 280 | fileHeaderHorizontalMargin: fileHeaderHorizontalMargin, | ||
| 281 | fileHeaderVerticalMargin: fileHeaderVerticalMargin, | ||
| 282 | fileHeaderCornerRadius: fileHeaderCornerRadius, | ||
| 283 | fileHeaderHorizontalPadding: fileHeaderHorizontalPadding, | ||
| 284 | fileHeaderPathRightPadding: fileHeaderPathRightPadding, | ||
| 285 | fileHeaderCountColumnWidth: fileHeaderCountColumnWidth, | ||
| 286 | fileHeaderCountGap: fileHeaderCountGap, | ||
| 287 | codeFontSize: codeFontSize, | ||
| 288 | codeFontWeight: codeFontWeight, | ||
| 289 | lineNumberFontSize: lineNumberFontSize, | ||
| 290 | lineNumberFontWeight: lineNumberFontWeight, | ||
| 291 | hunkFontSize: hunkFontSize, | ||
| 292 | hunkFontWeight: hunkFontWeight, | ||
| 293 | fileHeaderFontSize: fileHeaderFontSize, | ||
| 294 | fileHeaderFontWeight: fileHeaderFontWeight, | ||
| 295 | fileHeaderMetaFontSize: fileHeaderMetaFontSize, | ||
| 296 | fileHeaderMetaFontWeight: fileHeaderMetaFontWeight, | ||
| 297 | fileHeaderSubtextFontSize: fileHeaderSubtextFontSize, | ||
| 298 | fileHeaderSubtextFontWeight: fileHeaderSubtextFontWeight, | ||
| 299 | fileHeaderStatusFontSize: fileHeaderStatusFontSize, | ||
| 300 | fileHeaderStatusFontWeight: fileHeaderStatusFontWeight, | ||
| 301 | emptyStateFontSize: emptyStateFontSize, | ||
| 302 | emptyStateFontWeight: emptyStateFontWeight | ||
| 303 | ) | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ||
| 308 | private let scrollView = UIScrollView() | ||
| 309 | private let contentView = ReviewDiffContentView() | ||
| 310 | private var rows: [ReviewDiffNativeRow] = [] | ||
| 311 | private var appearanceScheme: String = "light" | ||
| 312 | private var themePayload: ReviewDiffNativeThemePayload? | ||
| 313 | private var stylePayload: ReviewDiffNativeStylePayload? | ||
| 314 | private var rowHeightOverride: CGFloat? | ||
| 315 | private var contentWidthOverride: CGFloat? | ||
| 316 | private var lastMetricsDebugKey = "" | ||
| 317 | private var lastVisibleRangeDebugKey = "" | ||
| 318 | private var tokensResetKey = "" | ||
| 319 | |||
| 320 | let onDebug = EventDispatcher() | ||
| 321 | let onToggleFile = EventDispatcher() | ||
| 322 | let onToggleViewedFile = EventDispatcher() | ||
| 323 | let onPressLine = EventDispatcher() | ||
| 324 | let onToggleComment = EventDispatcher() | ||
| 325 | |||
| 326 | public required init(appContext: AppContext? = nil) { | ||
| 327 | super.init(appContext: appContext) | ||
| 328 | |||
| 329 | clipsToBounds = true | ||
| 330 | backgroundColor = contentView.theme.background | ||
| 331 | scrollView.contentInsetAdjustmentBehavior = .never | ||
| 332 | scrollView.delegate = self | ||
| 333 | scrollView.clipsToBounds = true | ||
| 334 | scrollView.alwaysBounceVertical = true | ||
| 335 | scrollView.alwaysBounceHorizontal = false | ||
| 336 | scrollView.showsVerticalScrollIndicator = true | ||
| 337 | scrollView.showsHorizontalScrollIndicator = false | ||
| 338 | scrollView.backgroundColor = contentView.theme.background | ||
| 339 | addSubview(scrollView) | ||
| 340 | |||
| 341 | contentView.backgroundColor = contentView.theme.background | ||
| 342 | contentView.onToggleFile = { [weak self] fileId in | ||
| 343 | self?.onToggleFile(["fileId": fileId]) | ||
| 344 | } | ||
| 345 | contentView.onToggleViewedFile = { [weak self] fileId in | ||
| 346 | self?.onToggleViewedFile(["fileId": fileId]) | ||
| 347 | } | ||
| 348 | contentView.onPressLine = { [weak self] payload in | ||
| 349 | self?.onPressLine(payload) | ||
| 350 | } | ||
| 351 | contentView.onToggleComment = { [weak self] commentId in | ||
| 352 | self?.onToggleComment(["commentId": commentId]) | ||
| 353 | } | ||
| 354 | contentView.onDrawMetrics = { [weak self] metrics in | ||
| 355 | self?.emitDebug("draw-metrics", metrics) | ||
| 356 | } | ||
| 357 | scrollView.addSubview(contentView) | ||
| 358 | } | ||
| 359 | |||
| 360 | public override func layoutSubviews() { | ||
| 361 | super.layoutSubviews() | ||
| 362 | scrollView.frame = bounds | ||
| 363 | updateContentMetrics() | ||
| 364 | } | ||
| 365 | |||
| 366 | public func scrollViewDidScroll(_ scrollView: UIScrollView) { | ||
| 367 | if scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating { | ||
| 368 | contentView.isVerticalScrollActive = true | ||
| 369 | } | ||
| 370 | updateViewportFrame() | ||
| 371 | } | ||
| 372 | |||
| 373 | public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { | ||
| 374 | guard !decelerate else { | ||
| 375 | return | ||
| 376 | } | ||
| 377 | |||
| 378 | finishVerticalScroll() | ||
| 379 | } | ||
| 380 | |||
| 381 | public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { | ||
| 382 | finishVerticalScroll() | ||
| 383 | } | ||
| 384 | |||
| 385 | public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { | ||
| 386 | finishVerticalScroll() | ||
| 387 | } | ||
| 388 | |||
| 389 | func setRowsJson(_ rowsJson: String) { | ||
| 390 | guard let data = rowsJson.data(using: .utf8) else { | ||
| 391 | return | ||
| 392 | } | ||
| 393 | |||
| 394 | do { | ||
| 395 | rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) | ||
| 396 | contentView.rows = rows | ||
| 397 | emitDebug("rows-decoded", [ | ||
| 398 | "rows": rows.count, | ||
| 399 | "firstKind": rows.first?.kind ?? "none", | ||
| 400 | ]) | ||
| 401 | updateContentMetrics() | ||
| 402 | } catch { | ||
| 403 | rows = [] | ||
| 404 | contentView.rows = [] | ||
| 405 | updateContentMetrics() | ||
| 406 | emitDebug("rows-decode-failed", [ | ||
| 407 | "error": error.localizedDescription, | ||
| 408 | ]) | ||
| 409 | } | ||
| 410 | } | ||
| 411 | |||
| 412 | func setTokensJson(_ tokensJson: String) { | ||
| 413 | guard let data = tokensJson.data(using: .utf8) else { | ||
| 414 | return | ||
| 415 | } | ||
| 416 | |||
| 417 | do { | ||
| 418 | contentView.tokensByRowId = try JSONDecoder().decode( | ||
| 419 | [String: [ReviewDiffNativeToken]].self, | ||
| 420 | from: data | ||
| 421 | ) | ||
| 422 | } catch { | ||
| 423 | contentView.tokensByRowId = [:] | ||
| 424 | emitDebug("tokens-decode-failed", [ | ||
| 425 | "error": error.localizedDescription, | ||
| 426 | ]) | ||
| 427 | } | ||
| 428 | } | ||
| 429 | |||
| 430 | func setTokensPatchJson(_ tokensPatchJson: String) { | ||
| 431 | guard let data = tokensPatchJson.data(using: .utf8) else { | ||
| 432 | return | ||
| 433 | } | ||
| 434 | |||
| 435 | do { | ||
| 436 | let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) | ||
| 437 | if let resetKey = patch.resetKey, resetKey != tokensResetKey { | ||
| 438 | tokensResetKey = resetKey | ||
| 439 | contentView.tokensByRowId = [:] | ||
| 440 | } | ||
| 441 | |||
| 442 | let tokensByRowId = patch.tokensByRowId ?? [:] | ||
| 443 | if tokensByRowId.isEmpty { | ||
| 444 | return | ||
| 445 | } | ||
| 446 | |||
| 447 | contentView.mergeTokensByRowId(tokensByRowId) | ||
| 448 | if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { | ||
| 449 | emitDebug("tokens-patch-decoded", [ | ||
| 450 | "chunkIndex": chunkIndex, | ||
| 451 | "rows": tokensByRowId.count, | ||
| 452 | "totalRows": contentView.tokensByRowId.count, | ||
| 453 | ]) | ||
| 454 | } | ||
| 455 | } catch { | ||
| 456 | emitDebug("tokens-patch-decode-failed", [ | ||
| 457 | "error": error.localizedDescription, | ||
| 458 | ]) | ||
| 459 | } | ||
| 460 | } | ||
| 461 | |||
| 462 | func setTokensResetKey(_ tokensResetKey: String) { | ||
| 463 | guard tokensResetKey != self.tokensResetKey else { | ||
| 464 | return | ||
| 465 | } | ||
| 466 | |||
| 467 | self.tokensResetKey = tokensResetKey | ||
| 468 | contentView.tokensByRowId = [:] | ||
| 469 | emitDebug("tokens-reset", [ | ||
| 470 | "resetKey": tokensResetKey, | ||
| 471 | ]) | ||
| 472 | } | ||
| 473 | |||
| 474 | func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) { | ||
| 475 | let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson) | ||
| 476 | let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds) | ||
| 477 | let scrollAnchor: ReviewDiffScrollAnchor? | ||
| 478 | if changedFileIds.count == 1, let changedFileId = changedFileIds.first { | ||
| 479 | scrollAnchor = contentView.scrollAnchor(forFileId: changedFileId) | ||
| 480 | } else { | ||
| 481 | scrollAnchor = nil | ||
| 482 | } | ||
| 483 | |||
| 484 | contentView.collapsedFileIds = nextCollapsedFileIds | ||
| 485 | updateContentMetrics() | ||
| 486 | |||
| 487 | if let scrollAnchor, | ||
| 488 | let headerOffset = contentView.fileHeaderOffset(forFileId: scrollAnchor.fileId) { | ||
| 489 | let targetOffset = headerOffset - scrollAnchor.screenY | ||
| 490 | let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) | ||
| 491 | let clampedOffset = min(max(targetOffset, 0), maxOffset) | ||
| 492 | scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: false) | ||
| 493 | updateViewportFrame() | ||
| 494 | } | ||
| 495 | } | ||
| 496 | |||
| 497 | func setViewedFileIdsJson(_ viewedFileIdsJson: String) { | ||
| 498 | contentView.viewedFileIds = decodeFileIdSet(viewedFileIdsJson) | ||
| 499 | } | ||
| 500 | |||
| 501 | func setSelectedRowIdsJson(_ selectedRowIdsJson: String) { | ||
| 502 | contentView.selectedRowIds = decodeFileIdSet(selectedRowIdsJson) | ||
| 503 | } | ||
| 504 | |||
| 505 | func setCollapsedCommentIdsJson(_ collapsedCommentIdsJson: String) { | ||
| 506 | contentView.collapsedCommentIds = decodeFileIdSet(collapsedCommentIdsJson) | ||
| 507 | updateContentMetrics() | ||
| 508 | } | ||
| 509 | |||
| 510 | private func decodeFileIdSet(_ json: String) -> Set<String> { | ||
| 511 | guard let data = json.data(using: .utf8) else { | ||
| 512 | return [] | ||
| 513 | } | ||
| 514 | |||
| 515 | do { | ||
| 516 | return Set(try JSONDecoder().decode([String].self, from: data)) | ||
| 517 | } catch { | ||
| 518 | emitDebug("file-id-set-decode-failed", [ | ||
| 519 | "error": error.localizedDescription, | ||
| 520 | ]) | ||
| 521 | return [] | ||
| 522 | } | ||
| 523 | } | ||
| 524 | |||
| 525 | func setAppearanceScheme(_ appearanceScheme: String) { | ||
| 526 | self.appearanceScheme = appearanceScheme | ||
| 527 | applyTheme() | ||
| 528 | } | ||
| 529 | |||
| 530 | func setThemeJson(_ themeJson: String) { | ||
| 531 | guard let data = themeJson.data(using: .utf8) else { | ||
| 532 | themePayload = nil | ||
| 533 | applyTheme() | ||
| 534 | return | ||
| 535 | } | ||
| 536 | |||
| 537 | do { | ||
| 538 | themePayload = try JSONDecoder().decode(ReviewDiffNativeThemePayload.self, from: data) | ||
| 539 | } catch { | ||
| 540 | themePayload = nil | ||
| 541 | emitDebug("theme-decode-failed", [ | ||
| 542 | "error": error.localizedDescription, | ||
| 543 | ]) | ||
| 544 | } | ||
| 545 | |||
| 546 | applyTheme() | ||
| 547 | } | ||
| 548 | |||
| 549 | private func updateContentMetrics() { | ||
| 550 | let style = contentView.style | ||
| 551 | let height = max(bounds.height, contentView.contentHeight) | ||
| 552 | let width = bounds.width | ||
| 553 | scrollView.contentSize = CGSize(width: bounds.width, height: height) | ||
| 554 | contentView.frame = CGRect( | ||
| 555 | x: 0, | ||
| 556 | y: scrollView.contentOffset.y, | ||
| 557 | width: max(width, 1), | ||
| 558 | height: max(bounds.height, 1) | ||
| 559 | ) | ||
| 560 | contentView.viewportWidth = bounds.width | ||
| 561 | contentView.verticalOffset = scrollView.contentOffset.y | ||
| 562 | contentView.invalidateVisibleViewport() | ||
| 563 | contentView.setNeedsDisplay() | ||
| 564 | |||
| 565 | let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))" | ||
| 566 | if debugKey != lastMetricsDebugKey { | ||
| 567 | lastMetricsDebugKey = debugKey | ||
| 568 | emitDebug("metrics", [ | ||
| 569 | "rows": rows.count, | ||
| 570 | "boundsWidth": Double(bounds.width), | ||
| 571 | "boundsHeight": Double(bounds.height), | ||
| 572 | "contentHeight": Double(height), | ||
| 573 | "contentWidth": Double(style.contentWidth), | ||
| 574 | "fileHeaderHeight": Double(style.fileHeaderHeight), | ||
| 575 | "rowHeight": Double(style.rowHeight), | ||
| 576 | ]) | ||
| 577 | } | ||
| 578 | } | ||
| 579 | |||
| 580 | private func emitDebug(_ message: String, _ details: [String: Any]) { | ||
| 581 | var payload = details | ||
| 582 | payload["message"] = message | ||
| 583 | onDebug(payload) | ||
| 584 | } | ||
| 585 | |||
| 586 | private func finishVerticalScroll() { | ||
| 587 | contentView.isVerticalScrollActive = false | ||
| 588 | updateViewportFrame() | ||
| 589 | emitVisibleRange(reason: "scroll-end") | ||
| 590 | } | ||
| 591 | |||
| 592 | private func emitVisibleRange(reason: String) { | ||
| 593 | guard let range = contentView.currentVisibleRowRange() else { | ||
| 594 | return | ||
| 595 | } | ||
| 596 | |||
| 597 | let debugKey = "\(range.firstRowIndex):\(range.lastRowIndex):\(Int(scrollView.bounds.height))" | ||
| 598 | guard debugKey != lastVisibleRangeDebugKey else { | ||
| 599 | return | ||
| 600 | } | ||
| 601 | |||
| 602 | lastVisibleRangeDebugKey = debugKey | ||
| 603 | emitDebug("visible-range", [ | ||
| 604 | "reason": reason, | ||
| 605 | "firstRowIndex": range.firstRowIndex, | ||
| 606 | "lastRowIndex": range.lastRowIndex, | ||
| 607 | "totalRows": rows.count, | ||
| 608 | ]) | ||
| 609 | } | ||
| 610 | |||
| 611 | private func applyTheme() { | ||
| 612 | contentView.theme = ReviewDiffNativeTheme.resolve(appearanceScheme, payload: themePayload) | ||
| 613 | backgroundColor = contentView.theme.background | ||
| 614 | scrollView.backgroundColor = contentView.theme.background | ||
| 615 | contentView.backgroundColor = contentView.theme.background | ||
| 616 | contentView.invalidateVisibleViewport() | ||
| 617 | } | ||
| 618 | |||
| 619 | func setStyleJson(_ styleJson: String) { | ||
| 620 | guard let data = styleJson.data(using: .utf8) else { | ||
| 621 | stylePayload = nil | ||
| 622 | applyStyle() | ||
| 623 | return | ||
| 624 | } | ||
| 625 | |||
| 626 | do { | ||
| 627 | stylePayload = try JSONDecoder().decode(ReviewDiffNativeStylePayload.self, from: data) | ||
| 628 | } catch { | ||
| 629 | stylePayload = nil | ||
| 630 | emitDebug("style-decode-failed", [ | ||
| 631 | "error": error.localizedDescription, | ||
| 632 | ]) | ||
| 633 | } | ||
| 634 | |||
| 635 | applyStyle() | ||
| 636 | } | ||
| 637 | |||
| 638 | func setRowHeight(_ rowHeight: CGFloat) { | ||
| 639 | rowHeightOverride = rowHeight.isFinite && rowHeight > 0 ? rowHeight : nil | ||
| 640 | applyStyle() | ||
| 641 | } | ||
| 642 | |||
| 643 | func setContentWidth(_ contentWidth: CGFloat) { | ||
| 644 | contentWidthOverride = contentWidth.isFinite && contentWidth > 0 ? contentWidth : nil | ||
| 645 | applyStyle() | ||
| 646 | } | ||
| 647 | |||
| 648 | private func applyStyle() { | ||
| 649 | contentView.style = ReviewDiffNativeStyle | ||
| 650 | .resolve(stylePayload) | ||
| 651 | .applyingOverrides(rowHeight: rowHeightOverride, contentWidth: contentWidthOverride) | ||
| 652 | updateContentMetrics() | ||
| 653 | } | ||
| 654 | |||
| 655 | private func updateViewportFrame() { | ||
| 656 | contentView.frame = CGRect( | ||
| 657 | x: 0, | ||
| 658 | y: scrollView.contentOffset.y, | ||
| 659 | width: max(bounds.width, 1), | ||
| 660 | height: max(bounds.height, 1) | ||
| 661 | ) | ||
| 662 | contentView.verticalOffset = scrollView.contentOffset.y | ||
| 663 | contentView.invalidateVisibleViewport() | ||
| 664 | } | ||
| 665 | } | ||
| 666 | |||
| 667 | private enum ReviewDiffHorizontalPanKind { | ||
| 668 | case code | ||
| 669 | case fileHeaderPath | ||
| 670 | } | ||
| 671 | |||
| 672 | private struct ReviewDiffFileHeaderPathLayout { | ||
| 673 | let displayPath: String | ||
| 674 | let rect: CGRect | ||
| 675 | } | ||
| 676 | |||
| 677 | private struct ReviewDiffFileHeaderInteractiveRects { | ||
| 678 | let chevron: CGRect | ||
| 679 | let icon: CGRect | ||
| 680 | let checkbox: CGRect | ||
| 681 | } | ||
| 682 | |||
| 683 | private struct ReviewDiffStickyFileHeaderIndex { | ||
| 684 | let position: Int | ||
| 685 | let rowIndex: Int | ||
| 686 | } | ||
| 687 | |||
| 688 | private struct ReviewDiffStickyFileHeaderTarget { | ||
| 689 | let rowIndex: Int | ||
| 690 | let row: ReviewDiffNativeRow | ||
| 691 | let rect: CGRect | ||
| 692 | } | ||
| 693 | |||
| 694 | private struct ReviewDiffScrollAnchor { | ||
| 695 | let fileId: String | ||
| 696 | let screenY: CGFloat | ||
| 697 | } | ||
| 698 | |||
| 699 | private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ||
| 700 | var rows: [ReviewDiffNativeRow] = [] { | ||
| 701 | didSet { | ||
| 702 | stopHorizontalDeceleration() | ||
| 703 | horizontalOffsetsByFileId.removeAll() | ||
| 704 | headerPathOffsetsByFileId.removeAll() | ||
| 705 | activePanFileId = nil | ||
| 706 | activePanKind = nil | ||
| 707 | tokenAttributedStringsByRowId.removeAll() | ||
| 708 | rebuildRowLayout() | ||
| 709 | setNeedsDisplayForVisibleBounds() | ||
| 710 | } | ||
| 711 | } | ||
| 712 | var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] { | ||
| 713 | didSet { | ||
| 714 | tokenAttributedStringsByRowId.removeAll() | ||
| 715 | clampHorizontalOffsets() | ||
| 716 | setNeedsDisplayForVisibleBounds() | ||
| 717 | } | ||
| 718 | } | ||
| 719 | |||
| 720 | func mergeTokensByRowId(_ tokensPatch: [String: [ReviewDiffNativeToken]]) { | ||
| 721 | tokensPatch.forEach { rowId, tokens in | ||
| 722 | tokensByRowId[rowId] = tokens | ||
| 723 | tokenAttributedStringsByRowId.removeValue(forKey: rowId) | ||
| 724 | } | ||
| 725 | clampHorizontalOffsets() | ||
| 726 | setNeedsDisplayForVisibleBounds() | ||
| 727 | } | ||
| 728 | |||
| 729 | var collapsedFileIds: Set<String> = [] { | ||
| 730 | didSet { | ||
| 731 | rebuildRowLayout() | ||
| 732 | clampHorizontalOffsets() | ||
| 733 | setNeedsDisplayForVisibleBounds() | ||
| 734 | } | ||
| 735 | } | ||
| 736 | var viewedFileIds: Set<String> = [] { | ||
| 737 | didSet { | ||
| 738 | setNeedsDisplayForVisibleBounds() | ||
| 739 | } | ||
| 740 | } | ||
| 741 | var selectedRowIds: Set<String> = [] { | ||
| 742 | didSet { | ||
| 743 | setNeedsDisplayForVisibleBounds() | ||
| 744 | } | ||
| 745 | } | ||
| 746 | var collapsedCommentIds: Set<String> = [] { | ||
| 747 | didSet { | ||
| 748 | rebuildRowLayout() | ||
| 749 | setNeedsDisplayForVisibleBounds() | ||
| 750 | } | ||
| 751 | } | ||
| 752 | var style = ReviewDiffNativeStyle.resolve(nil) { | ||
| 753 | didSet { | ||
| 754 | tokenAttributedStringsByRowId.removeAll() | ||
| 755 | rebuildRowLayout() | ||
| 756 | clampHorizontalOffsets() | ||
| 757 | setNeedsDisplayForVisibleBounds() | ||
| 758 | } | ||
| 759 | } | ||
| 760 | var viewportWidth: CGFloat = 0 { | ||
| 761 | didSet { | ||
| 762 | clampHorizontalOffsets() | ||
| 763 | setNeedsDisplayForVisibleBounds() | ||
| 764 | } | ||
| 765 | } | ||
| 766 | var verticalOffset: CGFloat = 0 | ||
| 767 | var theme = ReviewDiffNativeTheme.resolve("light") { | ||
| 768 | didSet { | ||
| 769 | tokenColorsByHex.removeAll() | ||
| 770 | tokenAttributedStringsByRowId.removeAll() | ||
| 771 | setNeedsDisplayForVisibleBounds() | ||
| 772 | } | ||
| 773 | } | ||
| 774 | private(set) var contentHeight: CGFloat = 0 | ||
| 775 | |||
| 776 | private var rowOffsets: [CGFloat] = [] | ||
| 777 | private var fileHeaderRowIndices: [Int] = [] | ||
| 778 | private var contentWidthsByFileId: [String: CGFloat] = [:] | ||
| 779 | private var tokenColorsByHex: [String: UIColor] = [:] | ||
| 780 | private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:] | ||
| 781 | private var codeCharacterWidth: CGFloat = 8 | ||
| 782 | private var panStartHorizontalOffset: CGFloat = 0 | ||
| 783 | private var activePanFileId: String? | ||
| 784 | private var activePanKind: ReviewDiffHorizontalPanKind? | ||
| 785 | private var horizontalOffsetsByFileId: [String: CGFloat] = [:] | ||
| 786 | private var headerPathOffsetsByFileId: [String: CGFloat] = [:] | ||
| 787 | private var decelerationDisplayLink: CADisplayLink? | ||
| 788 | private var deceleratingFileId: String? | ||
| 789 | private var horizontalVelocity: CGFloat = 0 | ||
| 790 | private var lastDecelerationTimestamp: CFTimeInterval = 0 | ||
| 791 | private var lastDrawMetricsTimestamp: CFTimeInterval = 0 | ||
| 792 | var isVerticalScrollActive = false | ||
| 793 | var onToggleFile: ((String) -> Void)? | ||
| 794 | var onToggleViewedFile: ((String) -> Void)? | ||
| 795 | var onPressLine: (([String: Any]) -> Void)? | ||
| 796 | var onToggleComment: ((String) -> Void)? | ||
| 797 | var onDrawMetrics: (([String: Any]) -> Void)? | ||
| 798 | |||
| 799 | private var stickyWidth: CGFloat { | ||
| 800 | style.changeBarWidth + style.gutterWidth | ||
| 801 | } | ||
| 802 | |||
| 803 | private var codeStartX: CGFloat { | ||
| 804 | stickyWidth + style.codePadding | ||
| 805 | } | ||
| 806 | |||
| 807 | private func height(for row: ReviewDiffNativeRow) -> CGFloat { | ||
| 808 | if row.kind == "file" { | ||
| 809 | return style.fileHeaderHeight | ||
| 810 | } | ||
| 811 | if collapsedFileIds.contains(resolvedFileId(for: row)) { | ||
| 812 | return 0 | ||
| 813 | } | ||
| 814 | if row.kind == "notice" { | ||
| 815 | return max(style.rowHeight * 2, 44) | ||
| 816 | } | ||
| 817 | if row.kind == "comment" { | ||
| 818 | return collapsedCommentIds.contains(row.id) ? 44 : 124 | ||
| 819 | } | ||
| 820 | return style.rowHeight | ||
| 821 | } | ||
| 822 | |||
| 823 | private func rebuildRowLayout() { | ||
| 824 | var nextOffsets: [CGFloat] = [] | ||
| 825 | var nextFileHeaderRowIndices: [Int] = [] | ||
| 826 | nextOffsets.reserveCapacity(rows.count) | ||
| 827 | var maxColumnCountsByFileId: [String: Int] = [:] | ||
| 828 | var offset: CGFloat = 0 | ||
| 829 | |||
| 830 | for (index, row) in rows.enumerated() { | ||
| 831 | nextOffsets.append(offset) | ||
| 832 | if row.kind == "file" { | ||
| 833 | nextFileHeaderRowIndices.append(index) | ||
| 834 | } | ||
| 835 | offset += height(for: row) | ||
| 836 | |||
| 837 | let fileId = resolvedFileId(for: row) | ||
| 838 | switch row.kind { | ||
| 839 | case "line": | ||
| 840 | maxColumnCountsByFileId[fileId] = max( | ||
| 841 | maxColumnCountsByFileId[fileId] ?? 0, | ||
| 842 | row.content?.count ?? 0 | ||
| 843 | ) | ||
| 844 | case "hunk": | ||
| 845 | maxColumnCountsByFileId[fileId] = max( | ||
| 846 | maxColumnCountsByFileId[fileId] ?? 0, | ||
| 847 | row.text?.count ?? 0 | ||
| 848 | ) | ||
| 849 | default: | ||
| 850 | continue | ||
| 851 | } | ||
| 852 | } | ||
| 853 | |||
| 854 | let characterWidth = monospaceCharacterWidth(font: codeFont) | ||
| 855 | codeCharacterWidth = characterWidth | ||
| 856 | contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in | ||
| 857 | let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2 | ||
| 858 | return max(0, min(style.contentWidth, measuredWidth)) | ||
| 859 | } | ||
| 860 | rowOffsets = nextOffsets | ||
| 861 | fileHeaderRowIndices = nextFileHeaderRowIndices | ||
| 862 | contentHeight = offset | ||
| 863 | } | ||
| 864 | |||
| 865 | private var codeFont: UIFont { | ||
| 866 | UIFont.monospacedSystemFont(ofSize: style.codeFontSize, weight: style.codeFontWeight) | ||
| 867 | } | ||
| 868 | |||
| 869 | private var lineNumberFont: UIFont { | ||
| 870 | UIFont.monospacedSystemFont(ofSize: style.lineNumberFontSize, weight: style.lineNumberFontWeight) | ||
| 871 | } | ||
| 872 | |||
| 873 | private var hunkFont: UIFont { | ||
| 874 | UIFont.monospacedSystemFont(ofSize: style.hunkFontSize, weight: style.hunkFontWeight) | ||
| 875 | } | ||
| 876 | |||
| 877 | private var fileHeaderFont: UIFont { | ||
| 878 | UIFont.systemFont(ofSize: style.fileHeaderFontSize, weight: style.fileHeaderFontWeight) | ||
| 879 | } | ||
| 880 | |||
| 881 | private var fileHeaderMetaFont: UIFont { | ||
| 882 | UIFont.systemFont(ofSize: style.fileHeaderMetaFontSize, weight: style.fileHeaderMetaFontWeight) | ||
| 883 | } | ||
| 884 | |||
| 885 | private var fileHeaderSubtextFont: UIFont { | ||
| 886 | UIFont.systemFont(ofSize: style.fileHeaderSubtextFontSize, weight: style.fileHeaderSubtextFontWeight) | ||
| 887 | } | ||
| 888 | |||
| 889 | private var fileHeaderStatusFont: UIFont { | ||
| 890 | UIFont.systemFont(ofSize: style.fileHeaderStatusFontSize, weight: style.fileHeaderStatusFontWeight) | ||
| 891 | } | ||
| 892 | |||
| 893 | private var emptyStateFont: UIFont { | ||
| 894 | UIFont.systemFont(ofSize: style.emptyStateFontSize, weight: style.emptyStateFontWeight) | ||
| 895 | } | ||
| 896 | |||
| 897 | private lazy var horizontalPanGesture: UIPanGestureRecognizer = { | ||
| 898 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleHorizontalPan(_:))) | ||
| 899 | gesture.delegate = self | ||
| 900 | gesture.cancelsTouchesInView = false | ||
| 901 | return gesture | ||
| 902 | }() | ||
| 903 | |||
| 904 | private lazy var tapGesture: UITapGestureRecognizer = { | ||
| 905 | let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) | ||
| 906 | gesture.delegate = self | ||
| 907 | gesture.cancelsTouchesInView = false | ||
| 908 | return gesture | ||
| 909 | }() | ||
| 910 | |||
| 911 | private lazy var longPressGesture: UILongPressGestureRecognizer = { | ||
| 912 | let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) | ||
| 913 | gesture.delegate = self | ||
| 914 | gesture.cancelsTouchesInView = false | ||
| 915 | gesture.minimumPressDuration = 0.28 | ||
| 916 | return gesture | ||
| 917 | }() | ||
| 918 | |||
| 919 | override init(frame: CGRect) { | ||
| 920 | super.init(frame: frame) | ||
| 921 | isOpaque = true | ||
| 922 | contentMode = .redraw | ||
| 923 | addGestureRecognizer(horizontalPanGesture) | ||
| 924 | addGestureRecognizer(tapGesture) | ||
| 925 | addGestureRecognizer(longPressGesture) | ||
| 926 | tapGesture.require(toFail: longPressGesture) | ||
| 927 | } | ||
| 928 | |||
| 929 | func invalidateVisibleViewport() { | ||
| 930 | setNeedsDisplayForVisibleBounds() | ||
| 931 | } | ||
| 932 | |||
| 933 | private func setNeedsDisplayForVisibleBounds() { | ||
| 934 | setNeedsDisplay() | ||
| 935 | } | ||
| 936 | |||
| 937 | required init?(coder: NSCoder) { | ||
| 938 | super.init(coder: coder) | ||
| 939 | isOpaque = true | ||
| 940 | contentMode = .redraw | ||
| 941 | addGestureRecognizer(horizontalPanGesture) | ||
| 942 | addGestureRecognizer(tapGesture) | ||
| 943 | addGestureRecognizer(longPressGesture) | ||
| 944 | tapGesture.require(toFail: longPressGesture) | ||
| 945 | } | ||
| 946 | |||
| 947 | override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { | ||
| 948 | guard gestureRecognizer === horizontalPanGesture else { | ||
| 949 | return true | ||
| 950 | } | ||
| 951 | |||
| 952 | let velocity = horizontalPanGesture.velocity(in: self) | ||
| 953 | guard abs(velocity.x) > abs(velocity.y) * 1.25 else { | ||
| 954 | return false | ||
| 955 | } | ||
| 956 | |||
| 957 | guard let panTarget = horizontalPanTarget(at: horizontalPanGesture.location(in: self)) else { | ||
| 958 | return false | ||
| 959 | } | ||
| 960 | |||
| 961 | let currentOffset = horizontalOffset(for: panTarget.fileId, kind: panTarget.kind) | ||
| 962 | if velocity.x > 0 && currentOffset <= 0.5 { | ||
| 963 | return false | ||
| 964 | } | ||
| 965 | |||
| 966 | return maxHorizontalOffset(for: panTarget) > 0 | ||
| 967 | } | ||
| 968 | |||
| 969 | @objc private func handleTap(_ gesture: UITapGestureRecognizer) { | ||
| 970 | guard gesture.state == .ended else { | ||
| 971 | return | ||
| 972 | } | ||
| 973 | |||
| 974 | let point = gesture.location(in: self) | ||
| 975 | if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) { | ||
| 976 | handleFileHeaderTap(row: stickyHeader.row, rect: stickyHeader.rect, point: point) | ||
| 977 | return | ||
| 978 | } | ||
| 979 | |||
| 980 | guard let rowIndex = rowIndex(at: verticalOffset + point.y), | ||
| 981 | rows.indices.contains(rowIndex) else { | ||
| 982 | return | ||
| 983 | } | ||
| 984 | |||
| 985 | let row = rows[rowIndex] | ||
| 986 | if row.kind == "comment" { | ||
| 987 | onToggleComment?(row.id) | ||
| 988 | return | ||
| 989 | } | ||
| 990 | |||
| 991 | if row.kind == "line" { | ||
| 992 | onPressLine?(linePressPayload(for: row, gesture: "tap")) | ||
| 993 | return | ||
| 994 | } | ||
| 995 | |||
| 996 | guard row.kind == "file" else { | ||
| 997 | return | ||
| 998 | } | ||
| 999 | |||
| 1000 | let rowY = rowOffsets[rowIndex] - verticalOffset | ||
| 1001 | let rect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row)) | ||
| 1002 | handleFileHeaderTap(row: row, rect: rect, point: point) | ||
| 1003 | } | ||
| 1004 | |||
| 1005 | @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { | ||
| 1006 | guard gesture.state == .began else { | ||
| 1007 | return | ||
| 1008 | } | ||
| 1009 | |||
| 1010 | let point = gesture.location(in: self) | ||
| 1011 | if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) { | ||
| 1012 | return | ||
| 1013 | } | ||
| 1014 | |||
| 1015 | guard let rowIndex = rowIndex(at: verticalOffset + point.y), | ||
| 1016 | rows.indices.contains(rowIndex) else { | ||
| 1017 | return | ||
| 1018 | } | ||
| 1019 | |||
| 1020 | let row = rows[rowIndex] | ||
| 1021 | guard row.kind == "line" else { | ||
| 1022 | return | ||
| 1023 | } | ||
| 1024 | |||
| 1025 | onPressLine?(linePressPayload(for: row, gesture: "longPress")) | ||
| 1026 | } | ||
| 1027 | |||
| 1028 | private func linePressPayload(for row: ReviewDiffNativeRow, gesture: String) -> [String: Any] { | ||
| 1029 | var payload: [String: Any] = [ | ||
| 1030 | "rowId": row.id, | ||
| 1031 | "fileId": resolvedFileId(for: row), | ||
| 1032 | "gesture": gesture | ||
| 1033 | ] | ||
| 1034 | |||
| 1035 | if let oldLineNumber = row.oldLineNumber { | ||
| 1036 | payload["oldLineNumber"] = oldLineNumber | ||
| 1037 | } | ||
| 1038 | if let newLineNumber = row.newLineNumber { | ||
| 1039 | payload["newLineNumber"] = newLineNumber | ||
| 1040 | } | ||
| 1041 | if let change = row.change { | ||
| 1042 | payload["change"] = change | ||
| 1043 | } | ||
| 1044 | |||
| 1045 | return payload | ||
| 1046 | } | ||
| 1047 | |||
| 1048 | private func handleFileHeaderTap(row: ReviewDiffNativeRow, rect: CGRect, point: CGPoint) { | ||
| 1049 | let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: rect) | ||
| 1050 | let fileId = resolvedFileId(for: row) | ||
| 1051 | |||
| 1052 | if interactiveRects.checkbox.contains(point) { | ||
| 1053 | onToggleViewedFile?(fileId) | ||
| 1054 | return | ||
| 1055 | } | ||
| 1056 | |||
| 1057 | if rect.contains(point) { | ||
| 1058 | onToggleFile?(fileId) | ||
| 1059 | } | ||
| 1060 | } | ||
| 1061 | |||
| 1062 | func gestureRecognizer( | ||
| 1063 | _ gestureRecognizer: UIGestureRecognizer, | ||
| 1064 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer | ||
| 1065 | ) -> Bool { | ||
| 1066 | false | ||
| 1067 | } | ||
| 1068 | |||
| 1069 | @objc private func handleHorizontalPan(_ gesture: UIPanGestureRecognizer) { | ||
| 1070 | switch gesture.state { | ||
| 1071 | case .began: | ||
| 1072 | stopHorizontalDeceleration() | ||
| 1073 | let panTarget = horizontalPanTarget(at: gesture.location(in: self)) | ||
| 1074 | activePanFileId = panTarget?.fileId | ||
| 1075 | activePanKind = panTarget?.kind | ||
| 1076 | panStartHorizontalOffset = horizontalOffset(for: panTarget?.fileId, kind: panTarget?.kind) | ||
| 1077 | case .changed, .ended, .cancelled: | ||
| 1078 | guard let activePanFileId, let activePanKind else { | ||
| 1079 | return | ||
| 1080 | } | ||
| 1081 | let translation = gesture.translation(in: self) | ||
| 1082 | setHorizontalOffset( | ||
| 1083 | min( | ||
| 1084 | max(panStartHorizontalOffset - translation.x, 0), | ||
| 1085 | maxHorizontalOffset(for: (fileId: activePanFileId, kind: activePanKind)) | ||
| 1086 | ), | ||
| 1087 | for: activePanFileId, | ||
| 1088 | kind: activePanKind | ||
| 1089 | ) | ||
| 1090 | if gesture.state == .ended, activePanKind == .code { | ||
| 1091 | let velocity = -gesture.velocity(in: self).x | ||
| 1092 | self.activePanFileId = nil | ||
| 1093 | self.activePanKind = nil | ||
| 1094 | startHorizontalDeceleration(fileId: activePanFileId, velocity: velocity) | ||
| 1095 | } else if gesture.state == .cancelled { | ||
| 1096 | self.activePanFileId = nil | ||
| 1097 | self.activePanKind = nil | ||
| 1098 | } else if gesture.state == .ended { | ||
| 1099 | self.activePanFileId = nil | ||
| 1100 | self.activePanKind = nil | ||
| 1101 | } | ||
| 1102 | default: | ||
| 1103 | break | ||
| 1104 | } | ||
| 1105 | } | ||
| 1106 | |||
| 1107 | private func horizontalPanTarget(at point: CGPoint) -> (fileId: String, kind: ReviewDiffHorizontalPanKind)? { | ||
| 1108 | if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) { | ||
| 1109 | return (resolvedFileId(for: stickyHeader.row), .fileHeaderPath) | ||
| 1110 | } | ||
| 1111 | |||
| 1112 | guard let row = row(at: point) else { | ||
| 1113 | return nil | ||
| 1114 | } | ||
| 1115 | |||
| 1116 | let fileId = resolvedFileId(for: row) | ||
| 1117 | if row.kind == "file" { | ||
| 1118 | return (fileId, .fileHeaderPath) | ||
| 1119 | } | ||
| 1120 | |||
| 1121 | return (fileId, .code) | ||
| 1122 | } | ||
| 1123 | |||
| 1124 | private func stickyFileHeaderTarget() -> ReviewDiffStickyFileHeaderTarget? { | ||
| 1125 | guard let stickyHeader = stickyFileHeaderRowIndex() else { | ||
| 1126 | return nil | ||
| 1127 | } | ||
| 1128 | |||
| 1129 | let rowIndex = stickyHeader.rowIndex | ||
| 1130 | let headerTop = rowOffsets[rowIndex] | ||
| 1131 | guard headerTop < verticalOffset else { | ||
| 1132 | return nil | ||
| 1133 | } | ||
| 1134 | |||
| 1135 | let nextHeaderPosition = stickyHeader.position + 1 | ||
| 1136 | let nextHeaderRowIndex = fileHeaderRowIndices.indices.contains(nextHeaderPosition) | ||
| 1137 | ? fileHeaderRowIndices[nextHeaderPosition] | ||
| 1138 | : nil | ||
| 1139 | let pushedY: CGFloat | ||
| 1140 | if let nextHeaderRowIndex { | ||
| 1141 | pushedY = min(0, rowOffsets[nextHeaderRowIndex] - verticalOffset - style.fileHeaderHeight) | ||
| 1142 | } else { | ||
| 1143 | pushedY = 0 | ||
| 1144 | } | ||
| 1145 | |||
| 1146 | guard pushedY > -style.fileHeaderHeight else { | ||
| 1147 | return nil | ||
| 1148 | } | ||
| 1149 | |||
| 1150 | let rect = CGRect( | ||
| 1151 | x: 0, | ||
| 1152 | y: pushedY, | ||
| 1153 | width: max(bounds.width, viewportWidth), | ||
| 1154 | height: style.fileHeaderHeight | ||
| 1155 | ) | ||
| 1156 | return ReviewDiffStickyFileHeaderTarget(rowIndex: rowIndex, row: rows[rowIndex], rect: rect) | ||
| 1157 | } | ||
| 1158 | |||
| 1159 | private func stickyFileHeaderRowIndex() -> ReviewDiffStickyFileHeaderIndex? { | ||
| 1160 | guard !fileHeaderRowIndices.isEmpty else { | ||
| 1161 | return nil | ||
| 1162 | } | ||
| 1163 | |||
| 1164 | var lowerBound = 0 | ||
| 1165 | var upperBound = fileHeaderRowIndices.count | ||
| 1166 | while lowerBound < upperBound { | ||
| 1167 | let midpoint = (lowerBound + upperBound) / 2 | ||
| 1168 | let rowIndex = fileHeaderRowIndices[midpoint] | ||
| 1169 | if rowOffsets[rowIndex] <= verticalOffset { | ||
| 1170 | lowerBound = midpoint + 1 | ||
| 1171 | } else { | ||
| 1172 | upperBound = midpoint | ||
| 1173 | } | ||
| 1174 | } | ||
| 1175 | |||
| 1176 | let matchIndex = lowerBound - 1 | ||
| 1177 | guard matchIndex >= 0 else { | ||
| 1178 | return nil | ||
| 1179 | } | ||
| 1180 | return ReviewDiffStickyFileHeaderIndex(position: matchIndex, rowIndex: fileHeaderRowIndices[matchIndex]) | ||
| 1181 | } | ||
| 1182 | |||
| 1183 | func scrollAnchor(forFileId fileId: String) -> ReviewDiffScrollAnchor? { | ||
| 1184 | if let stickyHeader = stickyFileHeaderTarget(), | ||
| 1185 | resolvedFileId(for: stickyHeader.row) == fileId { | ||
| 1186 | return ReviewDiffScrollAnchor(fileId: fileId, screenY: 0) | ||
| 1187 | } | ||
| 1188 | |||
| 1189 | guard let headerOffset = fileHeaderOffset(forFileId: fileId) else { | ||
| 1190 | return nil | ||
| 1191 | } | ||
| 1192 | |||
| 1193 | return ReviewDiffScrollAnchor(fileId: fileId, screenY: headerOffset - verticalOffset) | ||
| 1194 | } | ||
| 1195 | |||
| 1196 | func fileHeaderOffset(forFileId fileId: String) -> CGFloat? { | ||
| 1197 | guard let rowIndex = fileHeaderRowIndex(forFileId: fileId) else { | ||
| 1198 | return nil | ||
| 1199 | } | ||
| 1200 | |||
| 1201 | return rowOffsets[rowIndex] | ||
| 1202 | } | ||
| 1203 | |||
| 1204 | private func fileHeaderRowIndex(forFileId fileId: String) -> Int? { | ||
| 1205 | fileHeaderRowIndices.first { rowIndex in | ||
| 1206 | rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId | ||
| 1207 | } | ||
| 1208 | } | ||
| 1209 | |||
| 1210 | private func row(at point: CGPoint) -> ReviewDiffNativeRow? { | ||
| 1211 | guard let rowIndex = rowIndex(at: verticalOffset + point.y) else { | ||
| 1212 | return nil | ||
| 1213 | } | ||
| 1214 | guard rows.indices.contains(rowIndex) else { | ||
| 1215 | return nil | ||
| 1216 | } | ||
| 1217 | return rows[rowIndex] | ||
| 1218 | } | ||
| 1219 | |||
| 1220 | private func rowIndex(at absoluteY: CGFloat) -> Int? { | ||
| 1221 | guard !rows.isEmpty else { | ||
| 1222 | return nil | ||
| 1223 | } | ||
| 1224 | |||
| 1225 | var lowerBound = 0 | ||
| 1226 | var upperBound = rows.count - 1 | ||
| 1227 | while lowerBound <= upperBound { | ||
| 1228 | let midpoint = (lowerBound + upperBound) / 2 | ||
| 1229 | let rowStart = rowOffsets[midpoint] | ||
| 1230 | let rowEnd = rowStart + height(for: rows[midpoint]) | ||
| 1231 | |||
| 1232 | if absoluteY < rowStart { | ||
| 1233 | upperBound = midpoint - 1 | ||
| 1234 | } else if absoluteY >= rowEnd { | ||
| 1235 | lowerBound = midpoint + 1 | ||
| 1236 | } else { | ||
| 1237 | return midpoint | ||
| 1238 | } | ||
| 1239 | } | ||
| 1240 | |||
| 1241 | return nil | ||
| 1242 | } | ||
| 1243 | |||
| 1244 | private func firstVisibleRowIndex(atOrAfter absoluteY: CGFloat) -> Int? { | ||
| 1245 | guard !rows.isEmpty else { | ||
| 1246 | return nil | ||
| 1247 | } | ||
| 1248 | |||
| 1249 | var lowerBound = 0 | ||
| 1250 | var upperBound = rows.count | ||
| 1251 | while lowerBound < upperBound { | ||
| 1252 | let midpoint = (lowerBound + upperBound) / 2 | ||
| 1253 | let rowEnd = rowOffsets[midpoint] + height(for: rows[midpoint]) | ||
| 1254 | |||
| 1255 | if rowEnd < absoluteY { | ||
| 1256 | lowerBound = midpoint + 1 | ||
| 1257 | } else { | ||
| 1258 | upperBound = midpoint | ||
| 1259 | } | ||
| 1260 | } | ||
| 1261 | |||
| 1262 | return lowerBound < rows.count ? lowerBound : nil | ||
| 1263 | } | ||
| 1264 | |||
| 1265 | private func lastVisibleRowIndex(atOrBefore absoluteY: CGFloat) -> Int? { | ||
| 1266 | guard !rows.isEmpty else { | ||
| 1267 | return nil | ||
| 1268 | } | ||
| 1269 | |||
| 1270 | var lowerBound = 0 | ||
| 1271 | var upperBound = rows.count | ||
| 1272 | while lowerBound < upperBound { | ||
| 1273 | let midpoint = (lowerBound + upperBound) / 2 | ||
| 1274 | let rowStart = rowOffsets[midpoint] | ||
| 1275 | |||
| 1276 | if rowStart <= absoluteY { | ||
| 1277 | lowerBound = midpoint + 1 | ||
| 1278 | } else { | ||
| 1279 | upperBound = midpoint | ||
| 1280 | } | ||
| 1281 | } | ||
| 1282 | |||
| 1283 | let index = lowerBound - 1 | ||
| 1284 | return index >= 0 ? index : nil | ||
| 1285 | } | ||
| 1286 | |||
| 1287 | private func resolvedFileId(for row: ReviewDiffNativeRow) -> String { | ||
| 1288 | if let fileId = row.fileId { | ||
| 1289 | return fileId | ||
| 1290 | } | ||
| 1291 | if let range = row.id.range(of: ":header") ?? row.id.range(of: ":hunk:") ?? row.id.range(of: ":line:") { | ||
| 1292 | return String(row.id[..<range.lowerBound]) | ||
| 1293 | } | ||
| 1294 | return row.filePath ?? row.id | ||
| 1295 | } | ||
| 1296 | |||
| 1297 | private func horizontalOffset( | ||
| 1298 | for fileId: String?, | ||
| 1299 | kind: ReviewDiffHorizontalPanKind? = .code | ||
| 1300 | ) -> CGFloat { | ||
| 1301 | guard let fileId else { | ||
| 1302 | return 0 | ||
| 1303 | } | ||
| 1304 | if kind == .fileHeaderPath { | ||
| 1305 | return headerPathOffsetsByFileId[fileId] ?? 0 | ||
| 1306 | } | ||
| 1307 | return horizontalOffsetsByFileId[fileId] ?? 0 | ||
| 1308 | } | ||
| 1309 | |||
| 1310 | private func setHorizontalOffset( | ||
| 1311 | _ offset: CGFloat, | ||
| 1312 | for fileId: String, | ||
| 1313 | kind: ReviewDiffHorizontalPanKind = .code | ||
| 1314 | ) { | ||
| 1315 | if kind == .fileHeaderPath { | ||
| 1316 | headerPathOffsetsByFileId[fileId] = offset | ||
| 1317 | setNeedsDisplayForVisibleBounds() | ||
| 1318 | return | ||
| 1319 | } | ||
| 1320 | horizontalOffsetsByFileId[fileId] = offset | ||
| 1321 | setNeedsDisplayForVisibleBounds() | ||
| 1322 | } | ||
| 1323 | |||
| 1324 | private func clampHorizontalOffsets() { | ||
| 1325 | for (fileId, offset) in horizontalOffsetsByFileId { | ||
| 1326 | horizontalOffsetsByFileId[fileId] = min(offset, maxHorizontalOffset(for: (fileId: fileId, kind: .code))) | ||
| 1327 | } | ||
| 1328 | for (fileId, offset) in headerPathOffsetsByFileId { | ||
| 1329 | headerPathOffsetsByFileId[fileId] = min( | ||
| 1330 | offset, | ||
| 1331 | maxHorizontalOffset(for: (fileId: fileId, kind: .fileHeaderPath)) | ||
| 1332 | ) | ||
| 1333 | } | ||
| 1334 | } | ||
| 1335 | |||
| 1336 | private func maxHorizontalOffset(for target: (fileId: String, kind: ReviewDiffHorizontalPanKind)) -> CGFloat { | ||
| 1337 | if target.kind == .fileHeaderPath, | ||
| 1338 | let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) { | ||
| 1339 | return maxHeaderPathOffset(for: row) | ||
| 1340 | } | ||
| 1341 | |||
| 1342 | return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX)) | ||
| 1343 | } | ||
| 1344 | |||
| 1345 | private func contentWidth(for fileId: String) -> CGFloat { | ||
| 1346 | contentWidthsByFileId[fileId] ?? min(style.contentWidth, max(viewportWidth, 0)) | ||
| 1347 | } | ||
| 1348 | |||
| 1349 | func currentVisibleRowRange() -> (firstRowIndex: Int, lastRowIndex: Int)? { | ||
| 1350 | guard !rows.isEmpty else { | ||
| 1351 | return nil | ||
| 1352 | } | ||
| 1353 | |||
| 1354 | let visibleMinY = verticalOffset | ||
| 1355 | let visibleMaxY = verticalOffset + max(bounds.height, 1) | ||
| 1356 | guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: visibleMinY), | ||
| 1357 | let lastRowIndex = lastVisibleRowIndex(atOrBefore: visibleMaxY), | ||
| 1358 | firstRowIndex <= lastRowIndex else { | ||
| 1359 | return nil | ||
| 1360 | } | ||
| 1361 | |||
| 1362 | return (firstRowIndex: firstRowIndex, lastRowIndex: lastRowIndex) | ||
| 1363 | } | ||
| 1364 | |||
| 1365 | private func maxHeaderPathOffset(for row: ReviewDiffNativeRow) -> CGFloat { | ||
| 1366 | let fullRect = CGRect(x: 0, y: 0, width: max(bounds.width, viewportWidth), height: height(for: row)) | ||
| 1367 | let layout = fileHeaderPathLayout(for: row, cardRect: fullRect) | ||
| 1368 | let pathWidth = textWidth(layout.displayPath, font: fileHeaderFont) | ||
| 1369 | return max(0, pathWidth - layout.rect.width) | ||
| 1370 | } | ||
| 1371 | |||
| 1372 | private func startHorizontalDeceleration(fileId: String, velocity: CGFloat) { | ||
| 1373 | guard abs(velocity) > 80 else { | ||
| 1374 | return | ||
| 1375 | } | ||
| 1376 | |||
| 1377 | let currentOffset = horizontalOffset(for: fileId) | ||
| 1378 | let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code)) | ||
| 1379 | if (currentOffset <= 0 && velocity < 0) || (currentOffset >= maxOffset && velocity > 0) { | ||
| 1380 | return | ||
| 1381 | } | ||
| 1382 | |||
| 1383 | stopHorizontalDeceleration() | ||
| 1384 | deceleratingFileId = fileId | ||
| 1385 | horizontalVelocity = velocity | ||
| 1386 | lastDecelerationTimestamp = 0 | ||
| 1387 | |||
| 1388 | let displayLink = CADisplayLink(target: self, selector: #selector(stepHorizontalDeceleration(_:))) | ||
| 1389 | displayLink.add(to: .main, forMode: .common) | ||
| 1390 | decelerationDisplayLink = displayLink | ||
| 1391 | } | ||
| 1392 | |||
| 1393 | @objc private func stepHorizontalDeceleration(_ displayLink: CADisplayLink) { | ||
| 1394 | guard let fileId = deceleratingFileId else { | ||
| 1395 | stopHorizontalDeceleration() | ||
| 1396 | return | ||
| 1397 | } | ||
| 1398 | |||
| 1399 | if lastDecelerationTimestamp == 0 { | ||
| 1400 | lastDecelerationTimestamp = displayLink.timestamp | ||
| 1401 | return | ||
| 1402 | } | ||
| 1403 | |||
| 1404 | let dt = max(0, displayLink.timestamp - lastDecelerationTimestamp) | ||
| 1405 | lastDecelerationTimestamp = displayLink.timestamp | ||
| 1406 | |||
| 1407 | let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code)) | ||
| 1408 | let nextOffset = horizontalOffset(for: fileId) + horizontalVelocity * CGFloat(dt) | ||
| 1409 | let clampedOffset = min(max(nextOffset, 0), maxOffset) | ||
| 1410 | setHorizontalOffset(clampedOffset, for: fileId) | ||
| 1411 | |||
| 1412 | // UIScrollView deceleration rates are expressed per millisecond. | ||
| 1413 | horizontalVelocity *= CGFloat(pow(Double(UIScrollView.DecelerationRate.normal.rawValue), dt * 1000)) | ||
| 1414 | |||
| 1415 | if abs(horizontalVelocity) < 20 || clampedOffset <= 0 || clampedOffset >= maxOffset { | ||
| 1416 | stopHorizontalDeceleration() | ||
| 1417 | } | ||
| 1418 | } | ||
| 1419 | |||
| 1420 | private func stopHorizontalDeceleration() { | ||
| 1421 | decelerationDisplayLink?.invalidate() | ||
| 1422 | decelerationDisplayLink = nil | ||
| 1423 | deceleratingFileId = nil | ||
| 1424 | horizontalVelocity = 0 | ||
| 1425 | lastDecelerationTimestamp = 0 | ||
| 1426 | } | ||
| 1427 | |||
| 1428 | deinit { | ||
| 1429 | stopHorizontalDeceleration() | ||
| 1430 | } | ||
| 1431 | |||
| 1432 | override func draw(_ rect: CGRect) { | ||
| 1433 | let drawStartedAt = CACurrentMediaTime() | ||
| 1434 | guard let context = UIGraphicsGetCurrentContext() else { | ||
| 1435 | return | ||
| 1436 | } | ||
| 1437 | |||
| 1438 | theme.background.setFill() | ||
| 1439 | context.fill(rect) | ||
| 1440 | |||
| 1441 | if rows.isEmpty { | ||
| 1442 | drawEmptyState(rect) | ||
| 1443 | return | ||
| 1444 | } | ||
| 1445 | |||
| 1446 | let visibleMinY = verticalOffset + rect.minY | ||
| 1447 | let visibleMaxY = verticalOffset + rect.maxY | ||
| 1448 | let overscan = max(style.rowHeight, style.fileHeaderHeight) * 4 | ||
| 1449 | let rangeMinY = max(0, visibleMinY - overscan) | ||
| 1450 | let rangeMaxY = visibleMaxY + overscan | ||
| 1451 | guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: rangeMinY), | ||
| 1452 | let lastRowIndex = lastVisibleRowIndex(atOrBefore: rangeMaxY), | ||
| 1453 | firstRowIndex <= lastRowIndex else { | ||
| 1454 | return | ||
| 1455 | } | ||
| 1456 | |||
| 1457 | var drawnRowCount = 0 | ||
| 1458 | for rowIndex in firstRowIndex...lastRowIndex { | ||
| 1459 | let rowStart = rowOffsets[rowIndex] | ||
| 1460 | let rowHeight = height(for: rows[rowIndex]) | ||
| 1461 | if rowHeight <= 0 { | ||
| 1462 | continue | ||
| 1463 | } | ||
| 1464 | let rowEnd = rowStart + rowHeight | ||
| 1465 | if rowEnd < visibleMinY || rowStart > visibleMaxY { | ||
| 1466 | continue | ||
| 1467 | } | ||
| 1468 | drawRow(rows[rowIndex], rowIndex: rowIndex, context: context) | ||
| 1469 | drawnRowCount += 1 | ||
| 1470 | } | ||
| 1471 | drawStickyFileHeader(context: context) | ||
| 1472 | |||
| 1473 | maybeEmitDrawMetrics( | ||
| 1474 | drawnRowCount: drawnRowCount, | ||
| 1475 | durationMs: (CACurrentMediaTime() - drawStartedAt) * 1000, | ||
| 1476 | firstRowIndex: firstRowIndex, | ||
| 1477 | lastRowIndex: lastRowIndex | ||
| 1478 | ) | ||
| 1479 | } | ||
| 1480 | |||
| 1481 | private func maybeEmitDrawMetrics( | ||
| 1482 | drawnRowCount: Int, | ||
| 1483 | durationMs: Double, | ||
| 1484 | firstRowIndex: Int, | ||
| 1485 | lastRowIndex: Int | ||
| 1486 | ) { | ||
| 1487 | guard !isVerticalScrollActive else { | ||
| 1488 | return | ||
| 1489 | } | ||
| 1490 | |||
| 1491 | let now = CACurrentMediaTime() | ||
| 1492 | guard now - lastDrawMetricsTimestamp >= 1 else { | ||
| 1493 | return | ||
| 1494 | } | ||
| 1495 | |||
| 1496 | lastDrawMetricsTimestamp = now | ||
| 1497 | onDrawMetrics?([ | ||
| 1498 | "drawnRows": drawnRowCount, | ||
| 1499 | "durationMs": durationMs, | ||
| 1500 | "firstRowIndex": firstRowIndex, | ||
| 1501 | "lastRowIndex": lastRowIndex, | ||
| 1502 | "scannedRows": lastRowIndex - firstRowIndex + 1, | ||
| 1503 | "totalRows": rows.count, | ||
| 1504 | ]) | ||
| 1505 | } | ||
| 1506 | |||
| 1507 | private func drawEmptyState(_ rect: CGRect) { | ||
| 1508 | let message = "No native diff rows" | ||
| 1509 | let attributes: [NSAttributedString.Key: Any] = [ | ||
| 1510 | .font: emptyStateFont, | ||
| 1511 | .foregroundColor: theme.mutedText, | ||
| 1512 | ] | ||
| 1513 | message.draw(at: CGPoint(x: 16, y: rect.minY + 16), withAttributes: attributes) | ||
| 1514 | } | ||
| 1515 | |||
| 1516 | private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) { | ||
| 1517 | let rowY = rowOffsets[rowIndex] - verticalOffset | ||
| 1518 | let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row)) | ||
| 1519 | |||
| 1520 | switch row.kind { | ||
| 1521 | case "file": | ||
| 1522 | drawFileRow(row, rect: fullRect, context: context) | ||
| 1523 | case "hunk": | ||
| 1524 | drawHunkRow(row, rect: fullRect, context: context) | ||
| 1525 | case "notice": | ||
| 1526 | drawNoticeRow(row, rect: fullRect, context: context) | ||
| 1527 | case "comment": | ||
| 1528 | drawCommentRow(row, rect: fullRect, context: context) | ||
| 1529 | default: | ||
| 1530 | drawCodeRow(row, rect: fullRect, context: context) | ||
| 1531 | } | ||
| 1532 | } | ||
| 1533 | |||
| 1534 | private func drawStickyFileHeader(context: CGContext) { | ||
| 1535 | guard let stickyHeader = stickyFileHeaderTarget() else { | ||
| 1536 | return | ||
| 1537 | } | ||
| 1538 | |||
| 1539 | drawFileRow(stickyHeader.row, rect: stickyHeader.rect, context: context) | ||
| 1540 | } | ||
| 1541 | |||
| 1542 | private func drawFileRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { | ||
| 1543 | theme.background.setFill() | ||
| 1544 | context.fill(rect) | ||
| 1545 | |||
| 1546 | let cardRect = rect | ||
| 1547 | theme.headerBackground.setFill() | ||
| 1548 | context.fill(cardRect) | ||
| 1549 | |||
| 1550 | let hairline = 1 / UIScreen.main.scale | ||
| 1551 | theme.border.setFill() | ||
| 1552 | context.fill(CGRect(x: cardRect.minX, y: cardRect.maxY - hairline, width: cardRect.width, height: hairline)) | ||
| 1553 | |||
| 1554 | let centerY = cardRect.midY | ||
| 1555 | let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect) | ||
| 1556 | let fileId = resolvedFileId(for: row) | ||
| 1557 | drawDisclosureChevron( | ||
| 1558 | rect: interactiveRects.chevron, | ||
| 1559 | color: theme.mutedText, | ||
| 1560 | collapsed: collapsedFileIds.contains(fileId) | ||
| 1561 | ) | ||
| 1562 | |||
| 1563 | drawFileIcon(rect: interactiveRects.icon, changeType: row.changeType) | ||
| 1564 | |||
| 1565 | drawViewedCheckbox(rect: interactiveRects.checkbox, checked: viewedFileIds.contains(fileId)) | ||
| 1566 | |||
| 1567 | let deletions = row.deletions ?? 0 | ||
| 1568 | let additions = row.additions ?? 0 | ||
| 1569 | let deleteText = "-\(deletions)" | ||
| 1570 | let addText = "+\(additions)" | ||
| 1571 | let deleteWidth = textWidth(deleteText, font: fileHeaderMetaFont) | ||
| 1572 | let addWidth = textWidth(addText, font: fileHeaderMetaFont) | ||
| 1573 | let countsGap = min(style.fileHeaderCountGap, 4) | ||
| 1574 | let countsWidth = deleteWidth + countsGap + addWidth | ||
| 1575 | let countsX = interactiveRects.checkbox.minX - 10 - countsWidth | ||
| 1576 | drawSingleLineText( | ||
| 1577 | deleteText, | ||
| 1578 | rect: CGRect(x: countsX, y: centerY - 9, width: deleteWidth, height: 18), | ||
| 1579 | color: theme.deleteText, | ||
| 1580 | font: fileHeaderMetaFont | ||
| 1581 | ) | ||
| 1582 | drawSingleLineText( | ||
| 1583 | addText, | ||
| 1584 | rect: CGRect(x: countsX + deleteWidth + countsGap, y: centerY - 9, width: addWidth, height: 18), | ||
| 1585 | color: theme.addText, | ||
| 1586 | font: fileHeaderMetaFont | ||
| 1587 | ) | ||
| 1588 | |||
| 1589 | let pathLayout = fileHeaderPathLayout(for: row, cardRect: cardRect) | ||
| 1590 | let pathOffset = horizontalOffset(for: resolvedFileId(for: row), kind: .fileHeaderPath) | ||
| 1591 | drawSingleLineText( | ||
| 1592 | pathLayout.displayPath, | ||
| 1593 | rect: pathLayout.rect, | ||
| 1594 | color: theme.text, | ||
| 1595 | font: fileHeaderFont, | ||
| 1596 | horizontalOffset: pathOffset | ||
| 1597 | ) | ||
| 1598 | drawFileHeaderPathScrollFade(row, pathRect: pathLayout.rect, horizontalOffset: pathOffset, context: context) | ||
| 1599 | } | ||
| 1600 | |||
| 1601 | private func drawNoticeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { | ||
| 1602 | guard !collapsedFileIds.contains(resolvedFileId(for: row)) else { | ||
| 1603 | return | ||
| 1604 | } | ||
| 1605 | |||
| 1606 | theme.background.setFill() | ||
| 1607 | context.fill(rect) | ||
| 1608 | |||
| 1609 | let hairline = 1 / UIScreen.main.scale | ||
| 1610 | theme.border.withAlphaComponent(0.65).setFill() | ||
| 1611 | context.fill(CGRect(x: 0, y: rect.maxY - hairline, width: rect.width, height: hairline)) | ||
| 1612 | |||
| 1613 | let iconSize: CGFloat = 16 | ||
| 1614 | let iconRect = CGRect( | ||
| 1615 | x: style.fileHeaderHorizontalPadding + 2, | ||
| 1616 | y: rect.midY - iconSize / 2, | ||
| 1617 | width: iconSize, | ||
| 1618 | height: iconSize | ||
| 1619 | ) | ||
| 1620 | drawNoticeIcon(rect: iconRect, color: theme.mutedText) | ||
| 1621 | |||
| 1622 | drawSingleLineText( | ||
| 1623 | row.text ?? "", | ||
| 1624 | rect: CGRect( | ||
| 1625 | x: iconRect.maxX + 10, | ||
| 1626 | y: rect.midY - fileHeaderSubtextFont.lineHeight / 2, | ||
| 1627 | width: max(24, viewportWidth - iconRect.maxX - 10 - style.fileHeaderHorizontalPadding), | ||
| 1628 | height: fileHeaderSubtextFont.lineHeight | ||
| 1629 | ), | ||
| 1630 | color: theme.mutedText, | ||
| 1631 | font: fileHeaderSubtextFont | ||
| 1632 | ) | ||
| 1633 | } | ||
| 1634 | |||
| 1635 | private func drawCommentRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { | ||
| 1636 | guard !collapsedFileIds.contains(resolvedFileId(for: row)) else { | ||
| 1637 | return | ||
| 1638 | } | ||
| 1639 | |||
| 1640 | theme.background.setFill() | ||
| 1641 | context.fill(rect) | ||
| 1642 | |||
| 1643 | let isCollapsed = collapsedCommentIds.contains(row.id) | ||
| 1644 | let cardInset = CGFloat(8) | ||
| 1645 | let cardRect = rect.insetBy(dx: cardInset, dy: 5) | ||
| 1646 | let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 10) | ||
| 1647 | theme.headerBackground.setFill() | ||
| 1648 | cardPath.fill() | ||
| 1649 | theme.border.withAlphaComponent(0.85).setStroke() | ||
| 1650 | cardPath.lineWidth = 1 | ||
| 1651 | cardPath.stroke() | ||
| 1652 | |||
| 1653 | let chevronRect = CGRect(x: cardRect.minX + 10, y: cardRect.minY + 11, width: 16, height: 16) | ||
| 1654 | drawDisclosureChevron(rect: chevronRect, color: theme.mutedText, collapsed: isCollapsed) | ||
| 1655 | |||
| 1656 | let title = "Comment on \(row.commentRangeLabel ?? "line")" | ||
| 1657 | drawSingleLineText( | ||
| 1658 | title, | ||
| 1659 | rect: CGRect( | ||
| 1660 | x: chevronRect.maxX + 10, | ||
| 1661 | y: cardRect.minY + 8, | ||
| 1662 | width: max(24, cardRect.width - chevronRect.maxX - 28), | ||
| 1663 | height: fileHeaderSubtextFont.lineHeight + 4 | ||
| 1664 | ), | ||
| 1665 | color: theme.mutedText, | ||
| 1666 | font: fileHeaderSubtextFont | ||
| 1667 | ) | ||
| 1668 | |||
| 1669 | guard !isCollapsed else { | ||
| 1670 | return | ||
| 1671 | } | ||
| 1672 | |||
| 1673 | let body = row.commentText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" | ||
| 1674 | drawMultilineText( | ||
| 1675 | body.isEmpty ? "Comment" : body, | ||
| 1676 | rect: CGRect( | ||
| 1677 | x: cardRect.minX + 18, | ||
| 1678 | y: cardRect.minY + 42, | ||
| 1679 | width: max(24, cardRect.width - 36), | ||
| 1680 | height: max(20, cardRect.height - 56) | ||
| 1681 | ), | ||
| 1682 | color: theme.text, | ||
| 1683 | font: fileHeaderSubtextFont, | ||
| 1684 | maximumLineCount: 3 | ||
| 1685 | ) | ||
| 1686 | } | ||
| 1687 | |||
| 1688 | private func fileHeaderPathLayout( | ||
| 1689 | for row: ReviewDiffNativeRow, | ||
| 1690 | cardRect: CGRect | ||
| 1691 | ) -> ReviewDiffFileHeaderPathLayout { | ||
| 1692 | let centerY = cardRect.midY | ||
| 1693 | let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect) | ||
| 1694 | |||
| 1695 | let deletions = row.deletions ?? 0 | ||
| 1696 | let additions = row.additions ?? 0 | ||
| 1697 | let deleteWidth = textWidth("-\(deletions)", font: fileHeaderMetaFont) | ||
| 1698 | let addWidth = textWidth("+\(additions)", font: fileHeaderMetaFont) | ||
| 1699 | let countsGap = min(style.fileHeaderCountGap, 4) | ||
| 1700 | let countsWidth = deleteWidth + countsGap + addWidth | ||
| 1701 | let countsX = interactiveRects.checkbox.minX - 10 - countsWidth | ||
| 1702 | let pathX = interactiveRects.icon.maxX + 10 | ||
| 1703 | let pathWidth = max(CGFloat(24), countsX - pathX - 12) | ||
| 1704 | let displayPath: String | ||
| 1705 | if let previousPath = row.previousPath, previousPath != row.filePath, let filePath = row.filePath { | ||
| 1706 | displayPath = "\(previousPath) -> \(filePath)" | ||
| 1707 | } else { | ||
| 1708 | displayPath = row.filePath ?? "" | ||
| 1709 | } | ||
| 1710 | |||
| 1711 | return ReviewDiffFileHeaderPathLayout( | ||
| 1712 | displayPath: displayPath, | ||
| 1713 | rect: CGRect(x: pathX, y: centerY - 10, width: pathWidth, height: 20) | ||
| 1714 | ) | ||
| 1715 | } | ||
| 1716 | |||
| 1717 | private func fileHeaderInteractiveRects( | ||
| 1718 | for row: ReviewDiffNativeRow, | ||
| 1719 | cardRect: CGRect | ||
| 1720 | ) -> ReviewDiffFileHeaderInteractiveRects { | ||
| 1721 | let centerY = cardRect.midY | ||
| 1722 | let horizontalPadding = style.fileHeaderHorizontalPadding | ||
| 1723 | let chevronRect = CGRect(x: cardRect.minX + horizontalPadding, y: centerY - 10, width: 20, height: 20) | ||
| 1724 | let iconRect = CGRect(x: chevronRect.maxX + 8, y: centerY - 10, width: 20, height: 20) | ||
| 1725 | let checkboxRect = CGRect(x: cardRect.maxX - horizontalPadding - 20, y: centerY - 10, width: 20, height: 20) | ||
| 1726 | return ReviewDiffFileHeaderInteractiveRects( | ||
| 1727 | chevron: chevronRect, | ||
| 1728 | icon: iconRect, | ||
| 1729 | checkbox: checkboxRect | ||
| 1730 | ) | ||
| 1731 | } | ||
| 1732 | |||
| 1733 | private func fileStatusText(_ changeType: String?) -> String { | ||
| 1734 | switch changeType { | ||
| 1735 | case "new": | ||
| 1736 | return "A" | ||
| 1737 | case "deleted": | ||
| 1738 | return "D" | ||
| 1739 | case "renamed": | ||
| 1740 | return "R" | ||
| 1741 | default: | ||
| 1742 | return "" | ||
| 1743 | } | ||
| 1744 | } | ||
| 1745 | |||
| 1746 | private func fileStatusColor(_ changeType: String?) -> UIColor { | ||
| 1747 | switch changeType { | ||
| 1748 | case "new": | ||
| 1749 | return theme.addText | ||
| 1750 | case "deleted": | ||
| 1751 | return theme.deleteText | ||
| 1752 | case "renamed", "rename-pure", "rename-changed": | ||
| 1753 | return theme.hunkText | ||
| 1754 | default: | ||
| 1755 | return theme.hunkText | ||
| 1756 | } | ||
| 1757 | } | ||
| 1758 | |||
| 1759 | private func drawStatusPill(_ text: String, rect: CGRect, color: UIColor, font: UIFont) { | ||
| 1760 | let path = UIBezierPath(roundedRect: rect, cornerRadius: rect.height / 2) | ||
| 1761 | color.withAlphaComponent(0.12).setFill() | ||
| 1762 | path.fill() | ||
| 1763 | drawCenteredText(text, rect: rect, color: color, font: font) | ||
| 1764 | } | ||
| 1765 | |||
| 1766 | private func drawFileIcon(rect: CGRect, changeType: String?) { | ||
| 1767 | let color = fileStatusColor(changeType) | ||
| 1768 | let outerPath = UIBezierPath(roundedRect: rect, cornerRadius: 6) | ||
| 1769 | color.setStroke() | ||
| 1770 | outerPath.lineWidth = 2 | ||
| 1771 | outerPath.stroke() | ||
| 1772 | |||
| 1773 | if isRenameChange(changeType) { | ||
| 1774 | drawRenameChevronIcon(rect: rect.insetBy(dx: 4.5, dy: 5), color: color) | ||
| 1775 | return | ||
| 1776 | } | ||
| 1777 | |||
| 1778 | let dotRect = CGRect(x: rect.midX - 3, y: rect.midY - 3, width: 6, height: 6) | ||
| 1779 | color.setFill() | ||
| 1780 | UIBezierPath(ovalIn: dotRect).fill() | ||
| 1781 | } | ||
| 1782 | |||
| 1783 | private func isRenameChange(_ changeType: String?) -> Bool { | ||
| 1784 | changeType == "renamed" || changeType == "rename-pure" || changeType == "rename-changed" | ||
| 1785 | } | ||
| 1786 | |||
| 1787 | private func drawDisclosureChevron(rect: CGRect, color: UIColor, collapsed: Bool) { | ||
| 1788 | guard let context = UIGraphicsGetCurrentContext() else { | ||
| 1789 | return | ||
| 1790 | } | ||
| 1791 | |||
| 1792 | context.saveGState() | ||
| 1793 | context.setStrokeColor(color.cgColor) | ||
| 1794 | context.setLineWidth(2) | ||
| 1795 | context.setLineCap(.round) | ||
| 1796 | context.setLineJoin(.round) | ||
| 1797 | if collapsed { | ||
| 1798 | context.move(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.minY + rect.height * 0.28)) | ||
| 1799 | context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.60, y: rect.midY)) | ||
| 1800 | context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.maxY - rect.height * 0.28)) | ||
| 1801 | } else { | ||
| 1802 | context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.minY + rect.height * 0.42)) | ||
| 1803 | context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.62)) | ||
| 1804 | context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.28, y: rect.minY + rect.height * 0.42)) | ||
| 1805 | } | ||
| 1806 | context.strokePath() | ||
| 1807 | context.restoreGState() | ||
| 1808 | } | ||
| 1809 | |||
| 1810 | private func drawRenameChevronIcon(rect: CGRect, color: UIColor) { | ||
| 1811 | guard let context = UIGraphicsGetCurrentContext() else { | ||
| 1812 | return | ||
| 1813 | } | ||
| 1814 | |||
| 1815 | context.saveGState() | ||
| 1816 | context.setStrokeColor(color.cgColor) | ||
| 1817 | context.setLineWidth(1.8) | ||
| 1818 | context.setLineCap(.round) | ||
| 1819 | context.setLineJoin(.round) | ||
| 1820 | |||
| 1821 | let chevronWidth = min(rect.width * 0.28, 3.6) | ||
| 1822 | let chevronHeight = min(rect.height, 8) | ||
| 1823 | let gap = min(rect.width * 0.18, 2.4) | ||
| 1824 | let totalWidth = chevronWidth * 2 + gap | ||
| 1825 | let startX = rect.midX - totalWidth / 2 | ||
| 1826 | let topY = rect.midY - chevronHeight / 2 | ||
| 1827 | let bottomY = rect.midY + chevronHeight / 2 | ||
| 1828 | |||
| 1829 | for x in [startX, startX + chevronWidth + gap] { | ||
| 1830 | context.move(to: CGPoint(x: x, y: topY)) | ||
| 1831 | context.addLine(to: CGPoint(x: x + chevronWidth, y: rect.midY)) | ||
| 1832 | context.addLine(to: CGPoint(x: x, y: bottomY)) | ||
| 1833 | } | ||
| 1834 | |||
| 1835 | context.strokePath() | ||
| 1836 | context.restoreGState() | ||
| 1837 | } | ||
| 1838 | |||
| 1839 | private func drawViewedCheckbox(rect: CGRect, checked: Bool) { | ||
| 1840 | let path = UIBezierPath(roundedRect: rect, cornerRadius: 6) | ||
| 1841 | if checked { | ||
| 1842 | theme.hunkText.setFill() | ||
| 1843 | path.fill() | ||
| 1844 | } | ||
| 1845 | (checked ? theme.hunkText : theme.mutedText).setStroke() | ||
| 1846 | path.lineWidth = 1.8 | ||
| 1847 | path.stroke() | ||
| 1848 | |||
| 1849 | guard checked, let context = UIGraphicsGetCurrentContext() else { | ||
| 1850 | return | ||
| 1851 | } | ||
| 1852 | |||
| 1853 | context.saveGState() | ||
| 1854 | context.setStrokeColor(theme.background.cgColor) | ||
| 1855 | context.setLineWidth(2) | ||
| 1856 | context.setLineCap(.round) | ||
| 1857 | context.setLineJoin(.round) | ||
| 1858 | context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.midY)) | ||
| 1859 | context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.44, y: rect.maxY - rect.height * 0.30)) | ||
| 1860 | context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.25, y: rect.minY + rect.height * 0.30)) | ||
| 1861 | context.strokePath() | ||
| 1862 | context.restoreGState() | ||
| 1863 | } | ||
| 1864 | |||
| 1865 | private func drawNoticeIcon(rect: CGRect, color: UIColor) { | ||
| 1866 | guard let context = UIGraphicsGetCurrentContext() else { | ||
| 1867 | return | ||
| 1868 | } | ||
| 1869 | |||
| 1870 | context.saveGState() | ||
| 1871 | context.setStrokeColor(color.cgColor) | ||
| 1872 | context.setLineWidth(1.7) | ||
| 1873 | context.setLineCap(.round) | ||
| 1874 | context.setLineJoin(.round) | ||
| 1875 | |||
| 1876 | context.strokeEllipse(in: rect.insetBy(dx: 1, dy: 1)) | ||
| 1877 | context.move(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.30)) | ||
| 1878 | context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.58)) | ||
| 1879 | context.strokePath() | ||
| 1880 | color.setFill() | ||
| 1881 | context.fillEllipse(in: CGRect(x: rect.midX - 1, y: rect.maxY - rect.height * 0.30, width: 2, height: 2)) | ||
| 1882 | context.restoreGState() | ||
| 1883 | } | ||
| 1884 | |||
| 1885 | private func drawCenteredText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) { | ||
| 1886 | let paragraphStyle = NSMutableParagraphStyle() | ||
| 1887 | paragraphStyle.alignment = .center | ||
| 1888 | let attributes: [NSAttributedString.Key: Any] = [ | ||
| 1889 | .font: font, | ||
| 1890 | .foregroundColor: color, | ||
| 1891 | .paragraphStyle: paragraphStyle, | ||
| 1892 | ] | ||
| 1893 | (text as NSString).draw(in: rect, withAttributes: attributes) | ||
| 1894 | } | ||
| 1895 | |||
| 1896 | private func drawHunkRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { | ||
| 1897 | let fileId = resolvedFileId(for: row) | ||
| 1898 | let horizontalOffset = horizontalOffset(for: fileId) | ||
| 1899 | let contentWidth = contentWidth(for: fileId) | ||
| 1900 | theme.hunkBackground.setFill() | ||
| 1901 | context.fill(rect) | ||
| 1902 | |||
| 1903 | context.saveGState() | ||
| 1904 | context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight)) | ||
| 1905 | drawText( | ||
| 1906 | row.text ?? "", | ||
| 1907 | rect: CGRect( | ||
| 1908 | x: codeStartX - horizontalOffset, | ||
| 1909 | y: centeredTextY(in: rect, font: hunkFont), | ||
| 1910 | width: contentWidth, | ||
| 1911 | height: hunkFont.lineHeight | ||
| 1912 | ), | ||
| 1913 | color: theme.hunkText, | ||
| 1914 | font: hunkFont | ||
| 1915 | ) | ||
| 1916 | context.restoreGState() | ||
| 1917 | } | ||
| 1918 | |||
| 1919 | private func drawCodeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { | ||
| 1920 | let fileId = resolvedFileId(for: row) | ||
| 1921 | let horizontalOffset = horizontalOffset(for: fileId) | ||
| 1922 | let contentWidth = contentWidth(for: fileId) | ||
| 1923 | let change = row.change ?? "context" | ||
| 1924 | rowBackground(for: change).setFill() | ||
| 1925 | context.fill(rect) | ||
| 1926 | |||
| 1927 | if change == "add" { | ||
| 1928 | theme.addBar.setFill() | ||
| 1929 | context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight)) | ||
| 1930 | } else if change == "delete" { | ||
| 1931 | drawDeleteStripes( | ||
| 1932 | rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight), | ||
| 1933 | context: context | ||
| 1934 | ) | ||
| 1935 | } | ||
| 1936 | |||
| 1937 | drawSelectionOverlay(row, rect: rect, context: context) | ||
| 1938 | |||
| 1939 | let lineNumber = row.newLineNumber ?? row.oldLineNumber | ||
| 1940 | if let lineNumber { | ||
| 1941 | drawRightAlignedText( | ||
| 1942 | "\(lineNumber)", | ||
| 1943 | rect: CGRect( | ||
| 1944 | x: style.changeBarWidth, | ||
| 1945 | y: centeredTextY(in: rect, font: lineNumberFont), | ||
| 1946 | width: style.gutterWidth - style.codePadding, | ||
| 1947 | height: lineNumberFont.lineHeight | ||
| 1948 | ), | ||
| 1949 | color: lineNumberColor(for: change), | ||
| 1950 | font: lineNumberFont | ||
| 1951 | ) | ||
| 1952 | } | ||
| 1953 | |||
| 1954 | context.saveGState() | ||
| 1955 | context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight)) | ||
| 1956 | let codeTextRect = CGRect( | ||
| 1957 | x: codeStartX - horizontalOffset, | ||
| 1958 | y: centeredTextY(in: rect, font: codeFont), | ||
| 1959 | width: contentWidth, | ||
| 1960 | height: codeFont.lineHeight | ||
| 1961 | ) | ||
| 1962 | drawWordDiffRanges(row, rowRect: rect, context: context, horizontalOffset: horizontalOffset) | ||
| 1963 | if let tokens = tokensByRowId[row.id], !tokens.isEmpty { | ||
| 1964 | drawTokenText( | ||
| 1965 | rowId: row.id, | ||
| 1966 | tokens, | ||
| 1967 | rect: codeTextRect, | ||
| 1968 | fallbackColor: theme.text, | ||
| 1969 | font: codeFont | ||
| 1970 | ) | ||
| 1971 | } else { | ||
| 1972 | drawText(row.content ?? "", rect: codeTextRect, color: theme.text, font: codeFont) | ||
| 1973 | } | ||
| 1974 | context.restoreGState() | ||
| 1975 | } | ||
| 1976 | |||
| 1977 | private func drawSelectionOverlay( | ||
| 1978 | _ row: ReviewDiffNativeRow, | ||
| 1979 | rect: CGRect, | ||
| 1980 | context: CGContext | ||
| 1981 | ) { | ||
| 1982 | guard selectedRowIds.contains(row.id) else { | ||
| 1983 | return | ||
| 1984 | } | ||
| 1985 | |||
| 1986 | let selectionColor = theme.hunkText.withAlphaComponent(0.22) | ||
| 1987 | selectionColor.setFill() | ||
| 1988 | context.fill(rect) | ||
| 1989 | |||
| 1990 | theme.hunkText.withAlphaComponent(0.95).setFill() | ||
| 1991 | context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) | ||
| 1992 | } | ||
| 1993 | |||
| 1994 | private func drawWordDiffRanges( | ||
| 1995 | _ row: ReviewDiffNativeRow, | ||
| 1996 | rowRect: CGRect, | ||
| 1997 | context: CGContext, | ||
| 1998 | horizontalOffset: CGFloat | ||
| 1999 | ) { | ||
| 2000 | guard let ranges = row.wordDiffRanges, !ranges.isEmpty else { | ||
| 2001 | return | ||
| 2002 | } | ||
| 2003 | |||
| 2004 | let change = row.change ?? "context" | ||
| 2005 | guard change == "add" || change == "delete" else { | ||
| 2006 | return | ||
| 2007 | } | ||
| 2008 | |||
| 2009 | let fillColor: UIColor | ||
| 2010 | if change == "add" { | ||
| 2011 | fillColor = theme.addBar.withAlphaComponent(0.28) | ||
| 2012 | } else { | ||
| 2013 | fillColor = theme.deleteBar.withAlphaComponent(0.28) | ||
| 2014 | } | ||
| 2015 | let highlightHeight = max(4, min(rowRect.height - 4, codeFont.lineHeight)) | ||
| 2016 | let highlightY = rowRect.midY - highlightHeight / 2 | ||
| 2017 | |||
| 2018 | fillColor.setFill() | ||
| 2019 | for range in ranges { | ||
| 2020 | guard range.end > range.start else { | ||
| 2021 | continue | ||
| 2022 | } | ||
| 2023 | |||
| 2024 | let startX = codeStartX - horizontalOffset + CGFloat(range.start) * codeCharacterWidth | ||
| 2025 | let width = max(2, CGFloat(range.end - range.start) * codeCharacterWidth) | ||
| 2026 | let highlightRect = CGRect( | ||
| 2027 | x: startX, | ||
| 2028 | y: highlightY, | ||
| 2029 | width: width, | ||
| 2030 | height: highlightHeight | ||
| 2031 | ) | ||
| 2032 | UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill() | ||
| 2033 | } | ||
| 2034 | } | ||
| 2035 | |||
| 2036 | private func rowBackground(for change: String) -> UIColor { | ||
| 2037 | if change == "add" { | ||
| 2038 | return theme.addBackground | ||
| 2039 | } | ||
| 2040 | if change == "delete" { | ||
| 2041 | return theme.deleteBackground | ||
| 2042 | } | ||
| 2043 | return theme.background | ||
| 2044 | } | ||
| 2045 | |||
| 2046 | private func lineNumberColor(for change: String) -> UIColor { | ||
| 2047 | if change == "add" { | ||
| 2048 | return theme.addText | ||
| 2049 | } | ||
| 2050 | if change == "delete" { | ||
| 2051 | return theme.deleteText | ||
| 2052 | } | ||
| 2053 | return theme.mutedText | ||
| 2054 | } | ||
| 2055 | |||
| 2056 | private func drawDeleteStripes(rect: CGRect, context: CGContext) { | ||
| 2057 | theme.deleteBar.setFill() | ||
| 2058 | var y = rect.minY | ||
| 2059 | while y < rect.maxY { | ||
| 2060 | context.fill(CGRect(x: rect.minX, y: y, width: rect.width, height: 1)) | ||
| 2061 | y += 2 | ||
| 2062 | } | ||
| 2063 | } | ||
| 2064 | |||
| 2065 | private func drawText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) { | ||
| 2066 | let attributes: [NSAttributedString.Key: Any] = [ | ||
| 2067 | .font: font, | ||
| 2068 | .foregroundColor: color, | ||
| 2069 | .ligature: 0, | ||
| 2070 | ] | ||
| 2071 | (text as NSString).draw(in: rect, withAttributes: attributes) | ||
| 2072 | } | ||
| 2073 | |||
| 2074 | private func drawMultilineText( | ||
| 2075 | _ text: String, | ||
| 2076 | rect: CGRect, | ||
| 2077 | color: UIColor, | ||
| 2078 | font: UIFont, | ||
| 2079 | maximumLineCount: Int | ||
| 2080 | ) { | ||
| 2081 | let paragraphStyle = NSMutableParagraphStyle() | ||
| 2082 | paragraphStyle.lineBreakMode = .byTruncatingTail | ||
| 2083 | paragraphStyle.lineSpacing = 2 | ||
| 2084 | |||
| 2085 | let attributes: [NSAttributedString.Key: Any] = [ | ||
| 2086 | .font: font, | ||
| 2087 | .foregroundColor: color, | ||
| 2088 | .paragraphStyle: paragraphStyle, | ||
| 2089 | .ligature: 0, | ||
| 2090 | ] | ||
| 2091 | let maxHeight = CGFloat(maximumLineCount) * (font.lineHeight + paragraphStyle.lineSpacing) | ||
| 2092 | (text as NSString).draw( | ||
| 2093 | in: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: min(rect.height, maxHeight)), | ||
| 2094 | withAttributes: attributes | ||
| 2095 | ) | ||
| 2096 | } | ||
| 2097 | |||
| 2098 | private func centeredTextY(in rect: CGRect, font: UIFont) -> CGFloat { | ||
| 2099 | rect.midY - font.lineHeight / 2 | ||
| 2100 | } | ||
| 2101 | |||
| 2102 | private func drawSingleLineText( | ||
| 2103 | _ text: String, | ||
| 2104 | rect: CGRect, | ||
| 2105 | color: UIColor, | ||
| 2106 | font: UIFont, | ||
| 2107 | horizontalOffset: CGFloat = 0 | ||
| 2108 | ) { | ||
| 2109 | guard let context = UIGraphicsGetCurrentContext() else { | ||
| 2110 | return | ||
| 2111 | } | ||
| 2112 | |||
| 2113 | let attributes: [NSAttributedString.Key: Any] = [ | ||
| 2114 | .font: font, | ||
| 2115 | .foregroundColor: color, | ||
| 2116 | .ligature: 0, | ||
| 2117 | ] | ||
| 2118 | context.saveGState() | ||
| 2119 | context.clip(to: rect) | ||
| 2120 | let textY = rect.midY - font.lineHeight / 2 | ||
| 2121 | (text as NSString).draw( | ||
| 2122 | at: CGPoint(x: rect.minX - horizontalOffset, y: textY), | ||
| 2123 | withAttributes: attributes | ||
| 2124 | ) | ||
| 2125 | context.restoreGState() | ||
| 2126 | } | ||
| 2127 | |||
| 2128 | private func drawFileHeaderPathScrollFade( | ||
| 2129 | _ row: ReviewDiffNativeRow, | ||
| 2130 | pathRect: CGRect, | ||
| 2131 | horizontalOffset: CGFloat, | ||
| 2132 | context: CGContext | ||
| 2133 | ) { | ||
| 2134 | let maxOffset = maxHeaderPathOffset(for: row) | ||
| 2135 | guard maxOffset > 0, pathRect.width > 0 else { | ||
| 2136 | return | ||
| 2137 | } | ||
| 2138 | |||
| 2139 | let fadeWidth = min(CGFloat(28), pathRect.width / 3) | ||
| 2140 | if horizontalOffset > 0.5 { | ||
| 2141 | drawHorizontalFade( | ||
| 2142 | rect: CGRect(x: pathRect.minX, y: pathRect.minY, width: fadeWidth, height: pathRect.height), | ||
| 2143 | color: theme.headerBackground, | ||
| 2144 | fadesToRight: false, | ||
| 2145 | context: context | ||
| 2146 | ) | ||
| 2147 | } | ||
| 2148 | |||
| 2149 | if horizontalOffset < maxOffset - 0.5 { | ||
| 2150 | drawHorizontalFade( | ||
| 2151 | rect: CGRect(x: pathRect.maxX - fadeWidth, y: pathRect.minY, width: fadeWidth, height: pathRect.height), | ||
| 2152 | color: theme.headerBackground, | ||
| 2153 | fadesToRight: true, | ||
| 2154 | context: context | ||
| 2155 | ) | ||
| 2156 | } | ||
| 2157 | } | ||
| 2158 | |||
| 2159 | private func drawHorizontalFade( | ||
| 2160 | rect: CGRect, | ||
| 2161 | color: UIColor, | ||
| 2162 | fadesToRight: Bool, | ||
| 2163 | context: CGContext | ||
| 2164 | ) { | ||
| 2165 | guard rect.width > 0, | ||
| 2166 | let gradient = CGGradient( | ||
| 2167 | colorsSpace: CGColorSpaceCreateDeviceRGB(), | ||
| 2168 | colors: [ | ||
| 2169 | color.withAlphaComponent(fadesToRight ? 0 : 1).cgColor, | ||
| 2170 | color.withAlphaComponent(fadesToRight ? 1 : 0).cgColor, | ||
| 2171 | ] as CFArray, | ||
| 2172 | locations: [0, 1] | ||
| 2173 | ) else { | ||
| 2174 | return | ||
| 2175 | } | ||
| 2176 | |||
| 2177 | context.saveGState() | ||
| 2178 | context.clip(to: rect) | ||
| 2179 | context.drawLinearGradient( | ||
| 2180 | gradient, | ||
| 2181 | start: CGPoint(x: rect.minX, y: rect.midY), | ||
| 2182 | end: CGPoint(x: rect.maxX, y: rect.midY), | ||
| 2183 | options: [] | ||
| 2184 | ) | ||
| 2185 | context.restoreGState() | ||
| 2186 | } | ||
| 2187 | |||
| 2188 | private func textWidth(_ text: String, font: UIFont) -> CGFloat { | ||
| 2189 | let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0] | ||
| 2190 | return ceil((text as NSString).size(withAttributes: attributes).width) | ||
| 2191 | } | ||
| 2192 | |||
| 2193 | private func monospaceCharacterWidth(font: UIFont) -> CGFloat { | ||
| 2194 | let sampleLength = 64 | ||
| 2195 | let sample = String(repeating: "M", count: sampleLength) | ||
| 2196 | let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0] | ||
| 2197 | return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength) | ||
| 2198 | } | ||
| 2199 | |||
| 2200 | private func drawTokenText( | ||
| 2201 | rowId: String, | ||
| 2202 | _ tokens: [ReviewDiffNativeToken], | ||
| 2203 | rect: CGRect, | ||
| 2204 | fallbackColor: UIColor, | ||
| 2205 | font: UIFont | ||
| 2206 | ) { | ||
| 2207 | let attributedText = tokenAttributedString( | ||
| 2208 | rowId: rowId, | ||
| 2209 | tokens: tokens, | ||
| 2210 | fallbackColor: fallbackColor, | ||
| 2211 | font: font | ||
| 2212 | ) | ||
| 2213 | attributedText.draw(in: rect) | ||
| 2214 | } | ||
| 2215 | |||
| 2216 | private func tokenAttributedString( | ||
| 2217 | rowId: String, | ||
| 2218 | tokens: [ReviewDiffNativeToken], | ||
| 2219 | fallbackColor: UIColor, | ||
| 2220 | font: UIFont | ||
| 2221 | ) -> NSAttributedString { | ||
| 2222 | if let cached = tokenAttributedStringsByRowId[rowId] { | ||
| 2223 | return cached | ||
| 2224 | } | ||
| 2225 | |||
| 2226 | let attributedString = NSMutableAttributedString(string: "") | ||
| 2227 | for token in tokens where !token.content.isEmpty { | ||
| 2228 | attributedString.append( | ||
| 2229 | NSAttributedString( | ||
| 2230 | string: token.content, | ||
| 2231 | attributes: [ | ||
| 2232 | .font: font, | ||
| 2233 | .foregroundColor: tokenColor(for: token.color, fallbackColor: fallbackColor), | ||
| 2234 | .ligature: 0, | ||
| 2235 | ] | ||
| 2236 | ) | ||
| 2237 | ) | ||
| 2238 | } | ||
| 2239 | |||
| 2240 | tokenAttributedStringsByRowId[rowId] = attributedString | ||
| 2241 | return attributedString | ||
| 2242 | } | ||
| 2243 | |||
| 2244 | private func tokenColor(for hex: String?, fallbackColor: UIColor) -> UIColor { | ||
| 2245 | guard let hex, !hex.isEmpty else { | ||
| 2246 | return fallbackColor | ||
| 2247 | } | ||
| 2248 | |||
| 2249 | if let color = tokenColorsByHex[hex] { | ||
| 2250 | return color | ||
| 2251 | } | ||
| 2252 | |||
| 2253 | guard let color = UIColor(reviewDiffHex: hex) else { | ||
| 2254 | return fallbackColor | ||
| 2255 | } | ||
| 2256 | |||
| 2257 | tokenColorsByHex[hex] = color | ||
| 2258 | return color | ||
| 2259 | } | ||
| 2260 | |||
| 2261 | private func drawRightAlignedText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) { | ||
| 2262 | let paragraphStyle = NSMutableParagraphStyle() | ||
| 2263 | paragraphStyle.alignment = .right | ||
| 2264 | let attributes: [NSAttributedString.Key: Any] = [ | ||
| 2265 | .font: font, | ||
| 2266 | .foregroundColor: color, | ||
| 2267 | .paragraphStyle: paragraphStyle, | ||
| 2268 | ] | ||
| 2269 | (text as NSString).draw(in: rect, withAttributes: attributes) | ||
| 2270 | } | ||
| 2271 | } | ||
| 2272 | |||
| 2273 | private extension UIColor { | ||
| 2274 | convenience init?(reviewDiffHex hex: String?) { | ||
| 2275 | guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { | ||
| 2276 | return nil | ||
| 2277 | } | ||
| 2278 | |||
| 2279 | if value.hasPrefix("#") { | ||
| 2280 | value.removeFirst() | ||
| 2281 | } | ||
| 2282 | |||
| 2283 | guard value.count == 6 || value.count == 8 else { | ||
| 2284 | return nil | ||
| 2285 | } | ||
| 2286 | |||
| 2287 | var rawValue: UInt64 = 0 | ||
| 2288 | guard Scanner(string: value).scanHexInt64(&rawValue) else { | ||
| 2289 | return nil | ||
| 2290 | } | ||
| 2291 | |||
| 2292 | let red: CGFloat | ||
| 2293 | let green: CGFloat | ||
| 2294 | let blue: CGFloat | ||
| 2295 | let alpha: CGFloat | ||
| 2296 | |||
| 2297 | if value.count == 8 { | ||
| 2298 | red = CGFloat((rawValue >> 24) & 0xff) / 255 | ||
| 2299 | green = CGFloat((rawValue >> 16) & 0xff) / 255 | ||
| 2300 | blue = CGFloat((rawValue >> 8) & 0xff) / 255 | ||
| 2301 | alpha = CGFloat(rawValue & 0xff) / 255 | ||
| 2302 | } else { | ||
| 2303 | red = CGFloat((rawValue >> 16) & 0xff) / 255 | ||
| 2304 | green = CGFloat((rawValue >> 8) & 0xff) / 255 | ||
| 2305 | blue = CGFloat(rawValue & 0xff) / 255 | ||
| 2306 | alpha = 1 | ||
| 2307 | } | ||
| 2308 | |||
| 2309 | self.init(red: red, green: green, blue: blue, alpha: alpha) | ||
| 2310 | } | ||
| 2311 | } | ||
apps/mobile/modules/t3-review-diff/package.json created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/mobile-review-diff-native", | ||
| 3 | "version": "0.0.0", | ||
| 4 | "private": true, | ||
| 5 | "expo-module": { | ||
| 6 | "ios": { | ||
| 7 | "modules": [ | ||
| 8 | "T3ReviewDiffModule" | ||
| 9 | ] | ||
| 10 | } | ||
| 11 | } | ||
| 12 | } | ||
apps/mobile/modules/t3-terminal/README.md created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | # T3 Mobile Terminal Native Module | ||
| 2 | |||
| 3 | This local Expo module owns the native terminal surface for the mobile app. | ||
| 4 | |||
| 5 | The JavaScript contract is intentionally small: | ||
| 6 | |||
| 7 | - input from the native surface is emitted as `{ data: string }` | ||
| 8 | - resize from the native surface is emitted as `{ cols: number, rows: number }` | ||
| 9 | - remote PTY output is delivered by the existing `WsRpcClient.terminal` RPC stream | ||
| 10 | |||
| 11 | The iOS implementation uses the vendored `GhosttyKit.xcframework` built from the Ghostty custom-I/O | ||
| 12 | fork, with T3's iOS 16 compatibility patch applied. `T3TerminalView` owns a `libghostty` surface and | ||
| 13 | uses that callback I/O model: | ||
| 14 | |||
| 15 | 1. initialize libghostty once for the process | ||
| 16 | 2. create one Ghostty app and surface per native view | ||
| 17 | 3. feed remote output into the surface with `ghostty_surface_feed_data` | ||
| 18 | 4. send user input back to JS with the write callback | ||
| 19 | 5. emit Ghostty's measured terminal size through `onResize` | ||
| 20 | |||
| 21 | Android currently implements the same view name (`T3TerminalSurface`) and event payloads so the | ||
| 22 | React Native screen and RPC code stay platform-neutral. The renderer backend can be replaced with a | ||
| 23 | future Android Ghostty build without changing JS. | ||
| 24 | |||
| 25 | Vendored Ghostty revision and license details are in `THIRD_PARTY_NOTICES.md`. | ||
| 26 | |||
| 27 | ## Rebuilding GhosttyKit | ||
| 28 | |||
| 29 | The checked-in `GhosttyKit.xcframework` is built from the Ghostty custom-I/O fork (https://github.com/Yash-Singh1/ghostty/tree/custom-io). | ||
| 30 | Set the directory to the cloned repository checked out on the `custom-io` branch to `GHOSTTY_SOURCE_DIR`. | ||
| 31 | |||
| 32 | ```bash | ||
| 33 | apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh | ||
| 34 | ``` | ||
| 35 | |||
| 36 | The script builds Ghostty with Zig 0.15.2, strips the iOS archives, and replaces only the | ||
| 37 | `ios-arm64` and `ios-arm64-simulator` slices. Xcode's Metal toolchain must be installed; if `metal` | ||
| 38 | fails, run `xcodebuild -downloadComponent MetalToolchain`. | ||
apps/mobile/modules/t3-terminal/T3TerminalNative.podspec created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | require 'json' | ||
| 2 | |||
| 3 | package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) | ||
| 4 | |||
| 5 | Pod::Spec.new do |s| | ||
| 6 | s.name = 'T3TerminalNative' | ||
| 7 | s.version = package['version'] | ||
| 8 | s.summary = 'Native terminal surface for T3 Code mobile.' | ||
| 9 | s.description = 'Native terminal surface bridge used by the T3 Code React Native app.' | ||
| 10 | s.homepage = 'https://t3tools.com' | ||
| 11 | s.license = { :type => 'UNLICENSED' } | ||
| 12 | s.author = { 'T3 Tools' => 'hello@t3tools.com' } | ||
| 13 | s.platforms = { :ios => '16.1' } | ||
| 14 | s.source = { :path => '.' } | ||
| 15 | s.source_files = 'ios/**/*.{h,m,mm,swift}' | ||
| 16 | s.vendored_frameworks = 'Vendor/libghostty/GhosttyKit.xcframework' | ||
| 17 | s.frameworks = 'IOSurface', 'Metal', 'MetalKit', 'QuartzCore', 'UIKit' | ||
| 18 | s.libraries = 'c++', 'z' | ||
| 19 | s.swift_version = '5.9' | ||
| 20 | s.dependency 'ExpoModulesCore' | ||
| 21 | end | ||
apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | # Third-Party Notices | ||
| 2 | |||
| 3 | ## Ghostty / libghostty | ||
| 4 | |||
| 5 | The iOS terminal renderer vendors `GhosttyKit.xcframework`, a libghostty build produced from T3's | ||
| 6 | iOS 16 support fork. That fork was created from VVTerm's custom-I/O Ghostty fork. | ||
| 7 | |||
| 8 | - Upstream project: https://github.com/ghostty-org/ghostty | ||
| 9 | - Custom-I/O base fork: https://github.com/wiedymi/ghostty/tree/custom-io | ||
| 10 | - Vendored source fork: https://github.com/Yash-Singh1/ghostty/tree/custom-io | ||
| 11 | - Vendored revision: `d36c3b8dffd0d756dd5e5f4933962f774a0e6753` | ||
| 12 | - Reference integration: https://github.com/vivy-company/vvterm | ||
| 13 | - License: MIT | ||
| 14 | |||
| 15 | Ghostty's MIT license applies to the vendored framework. Keep this notice in sync when updating | ||
| 16 | `Vendor/libghostty`. | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/Info.plist created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 3 | <plist version="1.0"> | ||
| 4 | <dict> | ||
| 5 | 	<key>AvailableLibraries</key> | ||
| 6 | 	<array> | ||
| 7 | 		<dict> | ||
| 8 | 			<key>BinaryPath</key> | ||
| 9 | 			<string>libghostty-fat.a</string> | ||
| 10 | 			<key>HeadersPath</key> | ||
| 11 | 			<string>Headers</string> | ||
| 12 | 			<key>LibraryIdentifier</key> | ||
| 13 | 			<string>ios-arm64</string> | ||
| 14 | 			<key>LibraryPath</key> | ||
| 15 | 			<string>libghostty-fat.a</string> | ||
| 16 | 			<key>SupportedArchitectures</key> | ||
| 17 | 			<array> | ||
| 18 | 				<string>arm64</string> | ||
| 19 | 			</array> | ||
| 20 | 			<key>SupportedPlatform</key> | ||
| 21 | 			<string>ios</string> | ||
| 22 | 		</dict> | ||
| 23 | 		<dict> | ||
| 24 | 			<key>BinaryPath</key> | ||
| 25 | 			<string>libghostty-fat.a</string> | ||
| 26 | 			<key>HeadersPath</key> | ||
| 27 | 			<string>Headers</string> | ||
| 28 | 			<key>LibraryIdentifier</key> | ||
| 29 | 			<string>ios-arm64-simulator</string> | ||
| 30 | 			<key>LibraryPath</key> | ||
| 31 | 			<string>libghostty-fat.a</string> | ||
| 32 | 			<key>SupportedArchitectures</key> | ||
| 33 | 			<array> | ||
| 34 | 				<string>arm64</string> | ||
| 35 | 			</array> | ||
| 36 | 			<key>SupportedPlatform</key> | ||
| 37 | 			<string>ios</string> | ||
| 38 | 			<key>SupportedPlatformVariant</key> | ||
| 39 | 			<string>simulator</string> | ||
| 40 | 		</dict> | ||
| 41 | 	</array> | ||
| 42 | 	<key>CFBundlePackageType</key> | ||
| 43 | 	<string>XFWK</string> | ||
| 44 | 	<key>XCFrameworkFormatVersion</key> | ||
| 45 | 	<string>1.0</string> | ||
| 46 | </dict> | ||
| 47 | </plist> | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h created+1218| ... | @@ -0,0 +1,1218 @@ | ||
| 1 | // Ghostty embedding API. The documentation for the embedding API is | ||
| 2 | // only within the Zig source files that define the implementations. This | ||
| 3 | // isn't meant to be a general purpose embedding API (yet) so there hasn't | ||
| 4 | // been documentation or example work beyond that. | ||
| 5 | // | ||
| 6 | // The only consumer of this API is the macOS app, but the API is built to | ||
| 7 | // be more general purpose. | ||
| 8 | #ifndef GHOSTTY_H | ||
| 9 | #define GHOSTTY_H | ||
| 10 | |||
| 11 | #ifdef __cplusplus | ||
| 12 | extern "C" { | ||
| 13 | #endif | ||
| 14 | |||
| 15 | #include <stdbool.h> | ||
| 16 | #include <stddef.h> | ||
| 17 | #include <stdint.h> | ||
| 18 | |||
| 19 | #ifdef _MSC_VER | ||
| 20 | #include <BaseTsd.h> | ||
| 21 | typedef SSIZE_T ssize_t; | ||
| 22 | #else | ||
| 23 | #include <sys/types.h> | ||
| 24 | #endif | ||
| 25 | |||
| 26 | //------------------------------------------------------------------- | ||
| 27 | // Macros | ||
| 28 | |||
| 29 | #define GHOSTTY_SUCCESS 0 | ||
| 30 | |||
| 31 | // Symbol visibility for shared library builds. On Windows, functions | ||
| 32 | // are exported from the DLL when building and imported when consuming. | ||
| 33 | // On other platforms with GCC/Clang, functions are marked with default | ||
| 34 | // visibility so they remain accessible when the library is built with | ||
| 35 | // -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC | ||
| 36 | // before including this header to make this a no-op. | ||
| 37 | #ifndef GHOSTTY_API | ||
| 38 | #if defined(GHOSTTY_STATIC) | ||
| 39 | #define GHOSTTY_API | ||
| 40 | #elif defined(_WIN32) || defined(_WIN64) | ||
| 41 | #ifdef GHOSTTY_BUILD_SHARED | ||
| 42 | #define GHOSTTY_API __declspec(dllexport) | ||
| 43 | #else | ||
| 44 | #define GHOSTTY_API __declspec(dllimport) | ||
| 45 | #endif | ||
| 46 | #elif defined(__GNUC__) && __GNUC__ >= 4 | ||
| 47 | #define GHOSTTY_API __attribute__((visibility("default"))) | ||
| 48 | #else | ||
| 49 | #define GHOSTTY_API | ||
| 50 | #endif | ||
| 51 | #endif | ||
| 52 | |||
| 53 | //------------------------------------------------------------------- | ||
| 54 | // Types | ||
| 55 | |||
| 56 | // Opaque types | ||
| 57 | typedef void* ghostty_app_t; | ||
| 58 | typedef void* ghostty_config_t; | ||
| 59 | typedef void* ghostty_surface_t; | ||
| 60 | typedef void* ghostty_inspector_t; | ||
| 61 | |||
| 62 | // All the types below are fully defined and must be kept in sync with | ||
| 63 | // their Zig counterparts. Any changes to these types MUST have an associated | ||
| 64 | // Zig change. | ||
| 65 | typedef enum { | ||
| 66 | GHOSTTY_PLATFORM_INVALID, | ||
| 67 | GHOSTTY_PLATFORM_MACOS, | ||
| 68 | GHOSTTY_PLATFORM_IOS, | ||
| 69 | } ghostty_platform_e; | ||
| 70 | |||
| 71 | // Callback for custom I/O write handler. | ||
| 72 | typedef void (*ghostty_surface_write_fn)(void* userdata, | ||
| 73 | const uint8_t* data, | ||
| 74 | size_t len); | ||
| 75 | |||
| 76 | typedef enum { | ||
| 77 | GHOSTTY_CLIPBOARD_STANDARD, | ||
| 78 | GHOSTTY_CLIPBOARD_SELECTION, | ||
| 79 | } ghostty_clipboard_e; | ||
| 80 | |||
| 81 | typedef struct { | ||
| 82 | const char *mime; | ||
| 83 | const char *data; | ||
| 84 | } ghostty_clipboard_content_s; | ||
| 85 | |||
| 86 | typedef enum { | ||
| 87 | GHOSTTY_CLIPBOARD_REQUEST_PASTE, | ||
| 88 | GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, | ||
| 89 | GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, | ||
| 90 | } ghostty_clipboard_request_e; | ||
| 91 | |||
| 92 | typedef enum { | ||
| 93 | GHOSTTY_MOUSE_RELEASE, | ||
| 94 | GHOSTTY_MOUSE_PRESS, | ||
| 95 | } ghostty_input_mouse_state_e; | ||
| 96 | |||
| 97 | typedef enum { | ||
| 98 | GHOSTTY_MOUSE_UNKNOWN, | ||
| 99 | GHOSTTY_MOUSE_LEFT, | ||
| 100 | GHOSTTY_MOUSE_RIGHT, | ||
| 101 | GHOSTTY_MOUSE_MIDDLE, | ||
| 102 | GHOSTTY_MOUSE_FOUR, | ||
| 103 | GHOSTTY_MOUSE_FIVE, | ||
| 104 | GHOSTTY_MOUSE_SIX, | ||
| 105 | GHOSTTY_MOUSE_SEVEN, | ||
| 106 | GHOSTTY_MOUSE_EIGHT, | ||
| 107 | GHOSTTY_MOUSE_NINE, | ||
| 108 | GHOSTTY_MOUSE_TEN, | ||
| 109 | GHOSTTY_MOUSE_ELEVEN, | ||
| 110 | } ghostty_input_mouse_button_e; | ||
| 111 | |||
| 112 | typedef enum { | ||
| 113 | GHOSTTY_MOUSE_MOMENTUM_NONE, | ||
| 114 | GHOSTTY_MOUSE_MOMENTUM_BEGAN, | ||
| 115 | GHOSTTY_MOUSE_MOMENTUM_STATIONARY, | ||
| 116 | GHOSTTY_MOUSE_MOMENTUM_CHANGED, | ||
| 117 | GHOSTTY_MOUSE_MOMENTUM_ENDED, | ||
| 118 | GHOSTTY_MOUSE_MOMENTUM_CANCELLED, | ||
| 119 | GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN, | ||
| 120 | } ghostty_input_mouse_momentum_e; | ||
| 121 | |||
| 122 | typedef enum { | ||
| 123 | GHOSTTY_COLOR_SCHEME_LIGHT = 0, | ||
| 124 | GHOSTTY_COLOR_SCHEME_DARK = 1, | ||
| 125 | } ghostty_color_scheme_e; | ||
| 126 | |||
| 127 | // This is a packed struct (see src/input/mouse.zig) but the C standard | ||
| 128 | // afaik doesn't let us reliably define packed structs so we build it up | ||
| 129 | // from scratch. | ||
| 130 | typedef int ghostty_input_scroll_mods_t; | ||
| 131 | |||
| 132 | typedef enum { | ||
| 133 | GHOSTTY_MODS_NONE = 0, | ||
| 134 | GHOSTTY_MODS_SHIFT = 1 << 0, | ||
| 135 | GHOSTTY_MODS_CTRL = 1 << 1, | ||
| 136 | GHOSTTY_MODS_ALT = 1 << 2, | ||
| 137 | GHOSTTY_MODS_SUPER = 1 << 3, | ||
| 138 | GHOSTTY_MODS_CAPS = 1 << 4, | ||
| 139 | GHOSTTY_MODS_NUM = 1 << 5, | ||
| 140 | GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6, | ||
| 141 | GHOSTTY_MODS_CTRL_RIGHT = 1 << 7, | ||
| 142 | GHOSTTY_MODS_ALT_RIGHT = 1 << 8, | ||
| 143 | GHOSTTY_MODS_SUPER_RIGHT = 1 << 9, | ||
| 144 | } ghostty_input_mods_e; | ||
| 145 | |||
| 146 | typedef enum { | ||
| 147 | GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0, | ||
| 148 | GHOSTTY_BINDING_FLAGS_ALL = 1 << 1, | ||
| 149 | GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2, | ||
| 150 | GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3, | ||
| 151 | } ghostty_binding_flags_e; | ||
| 152 | |||
| 153 | typedef enum { | ||
| 154 | GHOSTTY_ACTION_RELEASE, | ||
| 155 | GHOSTTY_ACTION_PRESS, | ||
| 156 | GHOSTTY_ACTION_REPEAT, | ||
| 157 | } ghostty_input_action_e; | ||
| 158 | |||
| 159 | // Based on: https://www.w3.org/TR/uievents-code/ | ||
| 160 | typedef enum { | ||
| 161 | GHOSTTY_KEY_UNIDENTIFIED, | ||
| 162 | |||
| 163 | // "Writing System Keys" § 3.1.1 | ||
| 164 | GHOSTTY_KEY_BACKQUOTE, | ||
| 165 | GHOSTTY_KEY_BACKSLASH, | ||
| 166 | GHOSTTY_KEY_BRACKET_LEFT, | ||
| 167 | GHOSTTY_KEY_BRACKET_RIGHT, | ||
| 168 | GHOSTTY_KEY_COMMA, | ||
| 169 | GHOSTTY_KEY_DIGIT_0, | ||
| 170 | GHOSTTY_KEY_DIGIT_1, | ||
| 171 | GHOSTTY_KEY_DIGIT_2, | ||
| 172 | GHOSTTY_KEY_DIGIT_3, | ||
| 173 | GHOSTTY_KEY_DIGIT_4, | ||
| 174 | GHOSTTY_KEY_DIGIT_5, | ||
| 175 | GHOSTTY_KEY_DIGIT_6, | ||
| 176 | GHOSTTY_KEY_DIGIT_7, | ||
| 177 | GHOSTTY_KEY_DIGIT_8, | ||
| 178 | GHOSTTY_KEY_DIGIT_9, | ||
| 179 | GHOSTTY_KEY_EQUAL, | ||
| 180 | GHOSTTY_KEY_INTL_BACKSLASH, | ||
| 181 | GHOSTTY_KEY_INTL_RO, | ||
| 182 | GHOSTTY_KEY_INTL_YEN, | ||
| 183 | GHOSTTY_KEY_A, | ||
| 184 | GHOSTTY_KEY_B, | ||
| 185 | GHOSTTY_KEY_C, | ||
| 186 | GHOSTTY_KEY_D, | ||
| 187 | GHOSTTY_KEY_E, | ||
| 188 | GHOSTTY_KEY_F, | ||
| 189 | GHOSTTY_KEY_G, | ||
| 190 | GHOSTTY_KEY_H, | ||
| 191 | GHOSTTY_KEY_I, | ||
| 192 | GHOSTTY_KEY_J, | ||
| 193 | GHOSTTY_KEY_K, | ||
| 194 | GHOSTTY_KEY_L, | ||
| 195 | GHOSTTY_KEY_M, | ||
| 196 | GHOSTTY_KEY_N, | ||
| 197 | GHOSTTY_KEY_O, | ||
| 198 | GHOSTTY_KEY_P, | ||
| 199 | GHOSTTY_KEY_Q, | ||
| 200 | GHOSTTY_KEY_R, | ||
| 201 | GHOSTTY_KEY_S, | ||
| 202 | GHOSTTY_KEY_T, | ||
| 203 | GHOSTTY_KEY_U, | ||
| 204 | GHOSTTY_KEY_V, | ||
| 205 | GHOSTTY_KEY_W, | ||
| 206 | GHOSTTY_KEY_X, | ||
| 207 | GHOSTTY_KEY_Y, | ||
| 208 | GHOSTTY_KEY_Z, | ||
| 209 | GHOSTTY_KEY_MINUS, | ||
| 210 | GHOSTTY_KEY_PERIOD, | ||
| 211 | GHOSTTY_KEY_QUOTE, | ||
| 212 | GHOSTTY_KEY_SEMICOLON, | ||
| 213 | GHOSTTY_KEY_SLASH, | ||
| 214 | |||
| 215 | // "Functional Keys" § 3.1.2 | ||
| 216 | GHOSTTY_KEY_ALT_LEFT, | ||
| 217 | GHOSTTY_KEY_ALT_RIGHT, | ||
| 218 | GHOSTTY_KEY_BACKSPACE, | ||
| 219 | GHOSTTY_KEY_CAPS_LOCK, | ||
| 220 | GHOSTTY_KEY_CONTEXT_MENU, | ||
| 221 | GHOSTTY_KEY_CONTROL_LEFT, | ||
| 222 | GHOSTTY_KEY_CONTROL_RIGHT, | ||
| 223 | GHOSTTY_KEY_ENTER, | ||
| 224 | GHOSTTY_KEY_META_LEFT, | ||
| 225 | GHOSTTY_KEY_META_RIGHT, | ||
| 226 | GHOSTTY_KEY_SHIFT_LEFT, | ||
| 227 | GHOSTTY_KEY_SHIFT_RIGHT, | ||
| 228 | GHOSTTY_KEY_SPACE, | ||
| 229 | GHOSTTY_KEY_TAB, | ||
| 230 | GHOSTTY_KEY_CONVERT, | ||
| 231 | GHOSTTY_KEY_KANA_MODE, | ||
| 232 | GHOSTTY_KEY_NON_CONVERT, | ||
| 233 | |||
| 234 | // "Control Pad Section" § 3.2 | ||
| 235 | GHOSTTY_KEY_DELETE, | ||
| 236 | GHOSTTY_KEY_END, | ||
| 237 | GHOSTTY_KEY_HELP, | ||
| 238 | GHOSTTY_KEY_HOME, | ||
| 239 | GHOSTTY_KEY_INSERT, | ||
| 240 | GHOSTTY_KEY_PAGE_DOWN, | ||
| 241 | GHOSTTY_KEY_PAGE_UP, | ||
| 242 | |||
| 243 | // "Arrow Pad Section" § 3.3 | ||
| 244 | GHOSTTY_KEY_ARROW_DOWN, | ||
| 245 | GHOSTTY_KEY_ARROW_LEFT, | ||
| 246 | GHOSTTY_KEY_ARROW_RIGHT, | ||
| 247 | GHOSTTY_KEY_ARROW_UP, | ||
| 248 | |||
| 249 | // "Numpad Section" § 3.4 | ||
| 250 | GHOSTTY_KEY_NUM_LOCK, | ||
| 251 | GHOSTTY_KEY_NUMPAD_0, | ||
| 252 | GHOSTTY_KEY_NUMPAD_1, | ||
| 253 | GHOSTTY_KEY_NUMPAD_2, | ||
| 254 | GHOSTTY_KEY_NUMPAD_3, | ||
| 255 | GHOSTTY_KEY_NUMPAD_4, | ||
| 256 | GHOSTTY_KEY_NUMPAD_5, | ||
| 257 | GHOSTTY_KEY_NUMPAD_6, | ||
| 258 | GHOSTTY_KEY_NUMPAD_7, | ||
| 259 | GHOSTTY_KEY_NUMPAD_8, | ||
| 260 | GHOSTTY_KEY_NUMPAD_9, | ||
| 261 | GHOSTTY_KEY_NUMPAD_ADD, | ||
| 262 | GHOSTTY_KEY_NUMPAD_BACKSPACE, | ||
| 263 | GHOSTTY_KEY_NUMPAD_CLEAR, | ||
| 264 | GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, | ||
| 265 | GHOSTTY_KEY_NUMPAD_COMMA, | ||
| 266 | GHOSTTY_KEY_NUMPAD_DECIMAL, | ||
| 267 | GHOSTTY_KEY_NUMPAD_DIVIDE, | ||
| 268 | GHOSTTY_KEY_NUMPAD_ENTER, | ||
| 269 | GHOSTTY_KEY_NUMPAD_EQUAL, | ||
| 270 | GHOSTTY_KEY_NUMPAD_MEMORY_ADD, | ||
| 271 | GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, | ||
| 272 | GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, | ||
| 273 | GHOSTTY_KEY_NUMPAD_MEMORY_STORE, | ||
| 274 | GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, | ||
| 275 | GHOSTTY_KEY_NUMPAD_MULTIPLY, | ||
| 276 | GHOSTTY_KEY_NUMPAD_PAREN_LEFT, | ||
| 277 | GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, | ||
| 278 | GHOSTTY_KEY_NUMPAD_SUBTRACT, | ||
| 279 | GHOSTTY_KEY_NUMPAD_SEPARATOR, | ||
| 280 | GHOSTTY_KEY_NUMPAD_UP, | ||
| 281 | GHOSTTY_KEY_NUMPAD_DOWN, | ||
| 282 | GHOSTTY_KEY_NUMPAD_RIGHT, | ||
| 283 | GHOSTTY_KEY_NUMPAD_LEFT, | ||
| 284 | GHOSTTY_KEY_NUMPAD_BEGIN, | ||
| 285 | GHOSTTY_KEY_NUMPAD_HOME, | ||
| 286 | GHOSTTY_KEY_NUMPAD_END, | ||
| 287 | GHOSTTY_KEY_NUMPAD_INSERT, | ||
| 288 | GHOSTTY_KEY_NUMPAD_DELETE, | ||
| 289 | GHOSTTY_KEY_NUMPAD_PAGE_UP, | ||
| 290 | GHOSTTY_KEY_NUMPAD_PAGE_DOWN, | ||
| 291 | |||
| 292 | // "Function Section" § 3.5 | ||
| 293 | GHOSTTY_KEY_ESCAPE, | ||
| 294 | GHOSTTY_KEY_F1, | ||
| 295 | GHOSTTY_KEY_F2, | ||
| 296 | GHOSTTY_KEY_F3, | ||
| 297 | GHOSTTY_KEY_F4, | ||
| 298 | GHOSTTY_KEY_F5, | ||
| 299 | GHOSTTY_KEY_F6, | ||
| 300 | GHOSTTY_KEY_F7, | ||
| 301 | GHOSTTY_KEY_F8, | ||
| 302 | GHOSTTY_KEY_F9, | ||
| 303 | GHOSTTY_KEY_F10, | ||
| 304 | GHOSTTY_KEY_F11, | ||
| 305 | GHOSTTY_KEY_F12, | ||
| 306 | GHOSTTY_KEY_F13, | ||
| 307 | GHOSTTY_KEY_F14, | ||
| 308 | GHOSTTY_KEY_F15, | ||
| 309 | GHOSTTY_KEY_F16, | ||
| 310 | GHOSTTY_KEY_F17, | ||
| 311 | GHOSTTY_KEY_F18, | ||
| 312 | GHOSTTY_KEY_F19, | ||
| 313 | GHOSTTY_KEY_F20, | ||
| 314 | GHOSTTY_KEY_F21, | ||
| 315 | GHOSTTY_KEY_F22, | ||
| 316 | GHOSTTY_KEY_F23, | ||
| 317 | GHOSTTY_KEY_F24, | ||
| 318 | GHOSTTY_KEY_F25, | ||
| 319 | GHOSTTY_KEY_FN, | ||
| 320 | GHOSTTY_KEY_FN_LOCK, | ||
| 321 | GHOSTTY_KEY_PRINT_SCREEN, | ||
| 322 | GHOSTTY_KEY_SCROLL_LOCK, | ||
| 323 | GHOSTTY_KEY_PAUSE, | ||
| 324 | |||
| 325 | // "Media Keys" § 3.6 | ||
| 326 | GHOSTTY_KEY_BROWSER_BACK, | ||
| 327 | GHOSTTY_KEY_BROWSER_FAVORITES, | ||
| 328 | GHOSTTY_KEY_BROWSER_FORWARD, | ||
| 329 | GHOSTTY_KEY_BROWSER_HOME, | ||
| 330 | GHOSTTY_KEY_BROWSER_REFRESH, | ||
| 331 | GHOSTTY_KEY_BROWSER_SEARCH, | ||
| 332 | GHOSTTY_KEY_BROWSER_STOP, | ||
| 333 | GHOSTTY_KEY_EJECT, | ||
| 334 | GHOSTTY_KEY_LAUNCH_APP_1, | ||
| 335 | GHOSTTY_KEY_LAUNCH_APP_2, | ||
| 336 | GHOSTTY_KEY_LAUNCH_MAIL, | ||
| 337 | GHOSTTY_KEY_MEDIA_PLAY_PAUSE, | ||
| 338 | GHOSTTY_KEY_MEDIA_SELECT, | ||
| 339 | GHOSTTY_KEY_MEDIA_STOP, | ||
| 340 | GHOSTTY_KEY_MEDIA_TRACK_NEXT, | ||
| 341 | GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, | ||
| 342 | GHOSTTY_KEY_POWER, | ||
| 343 | GHOSTTY_KEY_SLEEP, | ||
| 344 | GHOSTTY_KEY_AUDIO_VOLUME_DOWN, | ||
| 345 | GHOSTTY_KEY_AUDIO_VOLUME_MUTE, | ||
| 346 | GHOSTTY_KEY_AUDIO_VOLUME_UP, | ||
| 347 | GHOSTTY_KEY_WAKE_UP, | ||
| 348 | |||
| 349 | // "Legacy, Non-standard, and Special Keys" § 3.7 | ||
| 350 | GHOSTTY_KEY_COPY, | ||
| 351 | GHOSTTY_KEY_CUT, | ||
| 352 | GHOSTTY_KEY_PASTE, | ||
| 353 | } ghostty_input_key_e; | ||
| 354 | |||
| 355 | typedef struct { | ||
| 356 | ghostty_input_action_e action; | ||
| 357 | ghostty_input_mods_e mods; | ||
| 358 | ghostty_input_mods_e consumed_mods; | ||
| 359 | uint32_t keycode; | ||
| 360 | const char* text; | ||
| 361 | uint32_t unshifted_codepoint; | ||
| 362 | bool composing; | ||
| 363 | } ghostty_input_key_s; | ||
| 364 | |||
| 365 | typedef enum { | ||
| 366 | GHOSTTY_TRIGGER_PHYSICAL, | ||
| 367 | GHOSTTY_TRIGGER_UNICODE, | ||
| 368 | GHOSTTY_TRIGGER_CATCH_ALL, | ||
| 369 | } ghostty_input_trigger_tag_e; | ||
| 370 | |||
| 371 | typedef union { | ||
| 372 | ghostty_input_key_e translated; | ||
| 373 | ghostty_input_key_e physical; | ||
| 374 | uint32_t unicode; | ||
| 375 | // catch_all has no payload | ||
| 376 | } ghostty_input_trigger_key_u; | ||
| 377 | |||
| 378 | typedef struct { | ||
| 379 | ghostty_input_trigger_tag_e tag; | ||
| 380 | ghostty_input_trigger_key_u key; | ||
| 381 | ghostty_input_mods_e mods; | ||
| 382 | } ghostty_input_trigger_s; | ||
| 383 | |||
| 384 | typedef struct { | ||
| 385 | const char* action_key; | ||
| 386 | const char* action; | ||
| 387 | const char* title; | ||
| 388 | const char* description; | ||
| 389 | } ghostty_command_s; | ||
| 390 | |||
| 391 | typedef enum { | ||
| 392 | GHOSTTY_BUILD_MODE_DEBUG, | ||
| 393 | GHOSTTY_BUILD_MODE_RELEASE_SAFE, | ||
| 394 | GHOSTTY_BUILD_MODE_RELEASE_FAST, | ||
| 395 | GHOSTTY_BUILD_MODE_RELEASE_SMALL, | ||
| 396 | } ghostty_build_mode_e; | ||
| 397 | |||
| 398 | typedef struct { | ||
| 399 | ghostty_build_mode_e build_mode; | ||
| 400 | const char* version; | ||
| 401 | uintptr_t version_len; | ||
| 402 | } ghostty_info_s; | ||
| 403 | |||
| 404 | typedef struct { | ||
| 405 | const char* message; | ||
| 406 | } ghostty_diagnostic_s; | ||
| 407 | |||
| 408 | typedef struct { | ||
| 409 | const char* ptr; | ||
| 410 | uintptr_t len; | ||
| 411 | bool sentinel; | ||
| 412 | } ghostty_string_s; | ||
| 413 | |||
| 414 | typedef struct { | ||
| 415 | double tl_px_x; | ||
| 416 | double tl_px_y; | ||
| 417 | uint32_t offset_start; | ||
| 418 | uint32_t offset_len; | ||
| 419 | const char* text; | ||
| 420 | uintptr_t text_len; | ||
| 421 | } ghostty_text_s; | ||
| 422 | |||
| 423 | typedef enum { | ||
| 424 | GHOSTTY_POINT_ACTIVE, | ||
| 425 | GHOSTTY_POINT_VIEWPORT, | ||
| 426 | GHOSTTY_POINT_SCREEN, | ||
| 427 | GHOSTTY_POINT_SURFACE, | ||
| 428 | } ghostty_point_tag_e; | ||
| 429 | |||
| 430 | typedef enum { | ||
| 431 | GHOSTTY_POINT_COORD_EXACT, | ||
| 432 | GHOSTTY_POINT_COORD_TOP_LEFT, | ||
| 433 | GHOSTTY_POINT_COORD_BOTTOM_RIGHT, | ||
| 434 | } ghostty_point_coord_e; | ||
| 435 | |||
| 436 | typedef struct { | ||
| 437 | ghostty_point_tag_e tag; | ||
| 438 | ghostty_point_coord_e coord; | ||
| 439 | uint32_t x; | ||
| 440 | uint32_t y; | ||
| 441 | } ghostty_point_s; | ||
| 442 | |||
| 443 | typedef struct { | ||
| 444 | ghostty_point_s top_left; | ||
| 445 | ghostty_point_s bottom_right; | ||
| 446 | bool rectangle; | ||
| 447 | } ghostty_selection_s; | ||
| 448 | |||
| 449 | typedef struct { | ||
| 450 | const char* key; | ||
| 451 | const char* value; | ||
| 452 | } ghostty_env_var_s; | ||
| 453 | |||
| 454 | typedef struct { | ||
| 455 | void* nsview; | ||
| 456 | } ghostty_platform_macos_s; | ||
| 457 | |||
| 458 | typedef struct { | ||
| 459 | void* uiview; | ||
| 460 | } ghostty_platform_ios_s; | ||
| 461 | |||
| 462 | typedef union { | ||
| 463 | ghostty_platform_macos_s macos; | ||
| 464 | ghostty_platform_ios_s ios; | ||
| 465 | } ghostty_platform_u; | ||
| 466 | |||
| 467 | typedef enum { | ||
| 468 | GHOSTTY_SURFACE_CONTEXT_WINDOW = 0, | ||
| 469 | GHOSTTY_SURFACE_CONTEXT_TAB = 1, | ||
| 470 | GHOSTTY_SURFACE_CONTEXT_SPLIT = 2, | ||
| 471 | } ghostty_surface_context_e; | ||
| 472 | |||
| 473 | typedef struct { | ||
| 474 | ghostty_platform_e platform_tag; | ||
| 475 | ghostty_platform_u platform; | ||
| 476 | void* userdata; | ||
| 477 | double scale_factor; | ||
| 478 | float font_size; | ||
| 479 | const char* working_directory; | ||
| 480 | const char* command; | ||
| 481 | ghostty_env_var_s* env_vars; | ||
| 482 | size_t env_var_count; | ||
| 483 | const char* initial_input; | ||
| 484 | bool wait_after_command; | ||
| 485 | ghostty_surface_context_e context; | ||
| 486 | bool use_custom_io; | ||
| 487 | } ghostty_surface_config_s; | ||
| 488 | |||
| 489 | typedef struct { | ||
| 490 | uint16_t columns; | ||
| 491 | uint16_t rows; | ||
| 492 | uint32_t width_px; | ||
| 493 | uint32_t height_px; | ||
| 494 | uint32_t cell_width_px; | ||
| 495 | uint32_t cell_height_px; | ||
| 496 | } ghostty_surface_size_s; | ||
| 497 | |||
| 498 | // Config types | ||
| 499 | |||
| 500 | // config.Path | ||
| 501 | typedef struct { | ||
| 502 | const char* path; | ||
| 503 | bool optional; | ||
| 504 | } ghostty_config_path_s; | ||
| 505 | |||
| 506 | // config.Color | ||
| 507 | typedef struct { | ||
| 508 | uint8_t r; | ||
| 509 | uint8_t g; | ||
| 510 | uint8_t b; | ||
| 511 | } ghostty_config_color_s; | ||
| 512 | |||
| 513 | // config.ColorList | ||
| 514 | typedef struct { | ||
| 515 | const ghostty_config_color_s* colors; | ||
| 516 | size_t len; | ||
| 517 | } ghostty_config_color_list_s; | ||
| 518 | |||
| 519 | // config.RepeatableCommand | ||
| 520 | typedef struct { | ||
| 521 | const ghostty_command_s* commands; | ||
| 522 | size_t len; | ||
| 523 | } ghostty_config_command_list_s; | ||
| 524 | |||
| 525 | // config.Palette | ||
| 526 | typedef struct { | ||
| 527 | ghostty_config_color_s colors[256]; | ||
| 528 | } ghostty_config_palette_s; | ||
| 529 | |||
| 530 | // config.QuickTerminalSize | ||
| 531 | typedef enum { | ||
| 532 | GHOSTTY_QUICK_TERMINAL_SIZE_NONE, | ||
| 533 | GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE, | ||
| 534 | GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS, | ||
| 535 | } ghostty_quick_terminal_size_tag_e; | ||
| 536 | |||
| 537 | typedef union { | ||
| 538 | float percentage; | ||
| 539 | uint32_t pixels; | ||
| 540 | } ghostty_quick_terminal_size_value_u; | ||
| 541 | |||
| 542 | typedef struct { | ||
| 543 | ghostty_quick_terminal_size_tag_e tag; | ||
| 544 | ghostty_quick_terminal_size_value_u value; | ||
| 545 | } ghostty_quick_terminal_size_s; | ||
| 546 | |||
| 547 | typedef struct { | ||
| 548 | ghostty_quick_terminal_size_s primary; | ||
| 549 | ghostty_quick_terminal_size_s secondary; | ||
| 550 | } ghostty_config_quick_terminal_size_s; | ||
| 551 | |||
| 552 | // config.Fullscreen | ||
| 553 | typedef enum { | ||
| 554 | GHOSTTY_CONFIG_FULLSCREEN_FALSE, | ||
| 555 | GHOSTTY_CONFIG_FULLSCREEN_TRUE, | ||
| 556 | GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE, | ||
| 557 | GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, | ||
| 558 | GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, | ||
| 559 | } ghostty_config_fullscreen_e; | ||
| 560 | |||
| 561 | // apprt.Target.Key | ||
| 562 | typedef enum { | ||
| 563 | GHOSTTY_TARGET_APP, | ||
| 564 | GHOSTTY_TARGET_SURFACE, | ||
| 565 | } ghostty_target_tag_e; | ||
| 566 | |||
| 567 | typedef union { | ||
| 568 | ghostty_surface_t surface; | ||
| 569 | } ghostty_target_u; | ||
| 570 | |||
| 571 | typedef struct { | ||
| 572 | ghostty_target_tag_e tag; | ||
| 573 | ghostty_target_u target; | ||
| 574 | } ghostty_target_s; | ||
| 575 | |||
| 576 | // apprt.action.SplitDirection | ||
| 577 | typedef enum { | ||
| 578 | GHOSTTY_SPLIT_DIRECTION_RIGHT, | ||
| 579 | GHOSTTY_SPLIT_DIRECTION_DOWN, | ||
| 580 | GHOSTTY_SPLIT_DIRECTION_LEFT, | ||
| 581 | GHOSTTY_SPLIT_DIRECTION_UP, | ||
| 582 | } ghostty_action_split_direction_e; | ||
| 583 | |||
| 584 | // apprt.action.GotoSplit | ||
| 585 | typedef enum { | ||
| 586 | GHOSTTY_GOTO_SPLIT_PREVIOUS, | ||
| 587 | GHOSTTY_GOTO_SPLIT_NEXT, | ||
| 588 | GHOSTTY_GOTO_SPLIT_UP, | ||
| 589 | GHOSTTY_GOTO_SPLIT_LEFT, | ||
| 590 | GHOSTTY_GOTO_SPLIT_DOWN, | ||
| 591 | GHOSTTY_GOTO_SPLIT_RIGHT, | ||
| 592 | } ghostty_action_goto_split_e; | ||
| 593 | |||
| 594 | // apprt.action.GotoWindow | ||
| 595 | typedef enum { | ||
| 596 | GHOSTTY_GOTO_WINDOW_PREVIOUS, | ||
| 597 | GHOSTTY_GOTO_WINDOW_NEXT, | ||
| 598 | } ghostty_action_goto_window_e; | ||
| 599 | |||
| 600 | // apprt.action.ResizeSplit.Direction | ||
| 601 | typedef enum { | ||
| 602 | GHOSTTY_RESIZE_SPLIT_UP, | ||
| 603 | GHOSTTY_RESIZE_SPLIT_DOWN, | ||
| 604 | GHOSTTY_RESIZE_SPLIT_LEFT, | ||
| 605 | GHOSTTY_RESIZE_SPLIT_RIGHT, | ||
| 606 | } ghostty_action_resize_split_direction_e; | ||
| 607 | |||
| 608 | // apprt.action.ResizeSplit | ||
| 609 | typedef struct { | ||
| 610 | uint16_t amount; | ||
| 611 | ghostty_action_resize_split_direction_e direction; | ||
| 612 | } ghostty_action_resize_split_s; | ||
| 613 | |||
| 614 | // apprt.action.MoveTab | ||
| 615 | typedef struct { | ||
| 616 | ssize_t amount; | ||
| 617 | } ghostty_action_move_tab_s; | ||
| 618 | |||
| 619 | // apprt.action.GotoTab | ||
| 620 | typedef enum { | ||
| 621 | GHOSTTY_GOTO_TAB_PREVIOUS = -1, | ||
| 622 | GHOSTTY_GOTO_TAB_NEXT = -2, | ||
| 623 | GHOSTTY_GOTO_TAB_LAST = -3, | ||
| 624 | } ghostty_action_goto_tab_e; | ||
| 625 | |||
| 626 | // apprt.action.Fullscreen | ||
| 627 | typedef enum { | ||
| 628 | GHOSTTY_FULLSCREEN_NATIVE, | ||
| 629 | GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE, | ||
| 630 | GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU, | ||
| 631 | GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH, | ||
| 632 | } ghostty_action_fullscreen_e; | ||
| 633 | |||
| 634 | // apprt.action.FloatWindow | ||
| 635 | typedef enum { | ||
| 636 | GHOSTTY_FLOAT_WINDOW_ON, | ||
| 637 | GHOSTTY_FLOAT_WINDOW_OFF, | ||
| 638 | GHOSTTY_FLOAT_WINDOW_TOGGLE, | ||
| 639 | } ghostty_action_float_window_e; | ||
| 640 | |||
| 641 | // apprt.action.SecureInput | ||
| 642 | typedef enum { | ||
| 643 | GHOSTTY_SECURE_INPUT_ON, | ||
| 644 | GHOSTTY_SECURE_INPUT_OFF, | ||
| 645 | GHOSTTY_SECURE_INPUT_TOGGLE, | ||
| 646 | } ghostty_action_secure_input_e; | ||
| 647 | |||
| 648 | // apprt.action.Inspector | ||
| 649 | typedef enum { | ||
| 650 | GHOSTTY_INSPECTOR_TOGGLE, | ||
| 651 | GHOSTTY_INSPECTOR_SHOW, | ||
| 652 | GHOSTTY_INSPECTOR_HIDE, | ||
| 653 | } ghostty_action_inspector_e; | ||
| 654 | |||
| 655 | // apprt.action.QuitTimer | ||
| 656 | typedef enum { | ||
| 657 | GHOSTTY_QUIT_TIMER_START, | ||
| 658 | GHOSTTY_QUIT_TIMER_STOP, | ||
| 659 | } ghostty_action_quit_timer_e; | ||
| 660 | |||
| 661 | // apprt.action.Readonly | ||
| 662 | typedef enum { | ||
| 663 | GHOSTTY_READONLY_OFF, | ||
| 664 | GHOSTTY_READONLY_ON, | ||
| 665 | } ghostty_action_readonly_e; | ||
| 666 | |||
| 667 | // apprt.action.DesktopNotification.C | ||
| 668 | typedef struct { | ||
| 669 | const char* title; | ||
| 670 | const char* body; | ||
| 671 | } ghostty_action_desktop_notification_s; | ||
| 672 | |||
| 673 | // apprt.action.SetTitle.C | ||
| 674 | typedef struct { | ||
| 675 | const char* title; | ||
| 676 | } ghostty_action_set_title_s; | ||
| 677 | |||
| 678 | // apprt.action.PromptTitle | ||
| 679 | typedef enum { | ||
| 680 | GHOSTTY_PROMPT_TITLE_SURFACE, | ||
| 681 | GHOSTTY_PROMPT_TITLE_TAB, | ||
| 682 | } ghostty_action_prompt_title_e; | ||
| 683 | |||
| 684 | // apprt.action.Pwd.C | ||
| 685 | typedef struct { | ||
| 686 | const char* pwd; | ||
| 687 | } ghostty_action_pwd_s; | ||
| 688 | |||
| 689 | // terminal.MouseShape | ||
| 690 | typedef enum { | ||
| 691 | GHOSTTY_MOUSE_SHAPE_DEFAULT, | ||
| 692 | GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU, | ||
| 693 | GHOSTTY_MOUSE_SHAPE_HELP, | ||
| 694 | GHOSTTY_MOUSE_SHAPE_POINTER, | ||
| 695 | GHOSTTY_MOUSE_SHAPE_PROGRESS, | ||
| 696 | GHOSTTY_MOUSE_SHAPE_WAIT, | ||
| 697 | GHOSTTY_MOUSE_SHAPE_CELL, | ||
| 698 | GHOSTTY_MOUSE_SHAPE_CROSSHAIR, | ||
| 699 | GHOSTTY_MOUSE_SHAPE_TEXT, | ||
| 700 | GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT, | ||
| 701 | GHOSTTY_MOUSE_SHAPE_ALIAS, | ||
| 702 | GHOSTTY_MOUSE_SHAPE_COPY, | ||
| 703 | GHOSTTY_MOUSE_SHAPE_MOVE, | ||
| 704 | GHOSTTY_MOUSE_SHAPE_NO_DROP, | ||
| 705 | GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED, | ||
| 706 | GHOSTTY_MOUSE_SHAPE_GRAB, | ||
| 707 | GHOSTTY_MOUSE_SHAPE_GRABBING, | ||
| 708 | GHOSTTY_MOUSE_SHAPE_ALL_SCROLL, | ||
| 709 | GHOSTTY_MOUSE_SHAPE_COL_RESIZE, | ||
| 710 | GHOSTTY_MOUSE_SHAPE_ROW_RESIZE, | ||
| 711 | GHOSTTY_MOUSE_SHAPE_N_RESIZE, | ||
| 712 | GHOSTTY_MOUSE_SHAPE_E_RESIZE, | ||
| 713 | GHOSTTY_MOUSE_SHAPE_S_RESIZE, | ||
| 714 | GHOSTTY_MOUSE_SHAPE_W_RESIZE, | ||
| 715 | GHOSTTY_MOUSE_SHAPE_NE_RESIZE, | ||
| 716 | GHOSTTY_MOUSE_SHAPE_NW_RESIZE, | ||
| 717 | GHOSTTY_MOUSE_SHAPE_SE_RESIZE, | ||
| 718 | GHOSTTY_MOUSE_SHAPE_SW_RESIZE, | ||
| 719 | GHOSTTY_MOUSE_SHAPE_EW_RESIZE, | ||
| 720 | GHOSTTY_MOUSE_SHAPE_NS_RESIZE, | ||
| 721 | GHOSTTY_MOUSE_SHAPE_NESW_RESIZE, | ||
| 722 | GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE, | ||
| 723 | GHOSTTY_MOUSE_SHAPE_ZOOM_IN, | ||
| 724 | GHOSTTY_MOUSE_SHAPE_ZOOM_OUT, | ||
| 725 | } ghostty_action_mouse_shape_e; | ||
| 726 | |||
| 727 | // apprt.action.MouseVisibility | ||
| 728 | typedef enum { | ||
| 729 | GHOSTTY_MOUSE_VISIBLE, | ||
| 730 | GHOSTTY_MOUSE_HIDDEN, | ||
| 731 | } ghostty_action_mouse_visibility_e; | ||
| 732 | |||
| 733 | // apprt.action.MouseOverLink | ||
| 734 | typedef struct { | ||
| 735 | const char* url; | ||
| 736 | size_t len; | ||
| 737 | } ghostty_action_mouse_over_link_s; | ||
| 738 | |||
| 739 | // apprt.action.SizeLimit | ||
| 740 | typedef struct { | ||
| 741 | uint32_t min_width; | ||
| 742 | uint32_t min_height; | ||
| 743 | uint32_t max_width; | ||
| 744 | uint32_t max_height; | ||
| 745 | } ghostty_action_size_limit_s; | ||
| 746 | |||
| 747 | // apprt.action.InitialSize | ||
| 748 | typedef struct { | ||
| 749 | uint32_t width; | ||
| 750 | uint32_t height; | ||
| 751 | } ghostty_action_initial_size_s; | ||
| 752 | |||
| 753 | // apprt.action.CellSize | ||
| 754 | typedef struct { | ||
| 755 | uint32_t width; | ||
| 756 | uint32_t height; | ||
| 757 | } ghostty_action_cell_size_s; | ||
| 758 | |||
| 759 | // renderer.Health | ||
| 760 | typedef enum { | ||
| 761 | GHOSTTY_RENDERER_HEALTH_HEALTHY, | ||
| 762 | GHOSTTY_RENDERER_HEALTH_UNHEALTHY, | ||
| 763 | } ghostty_action_renderer_health_e; | ||
| 764 | |||
| 765 | // apprt.action.KeySequence | ||
| 766 | typedef struct { | ||
| 767 | bool active; | ||
| 768 | ghostty_input_trigger_s trigger; | ||
| 769 | } ghostty_action_key_sequence_s; | ||
| 770 | |||
| 771 | // apprt.action.KeyTable.Tag | ||
| 772 | typedef enum { | ||
| 773 | GHOSTTY_KEY_TABLE_ACTIVATE, | ||
| 774 | GHOSTTY_KEY_TABLE_DEACTIVATE, | ||
| 775 | GHOSTTY_KEY_TABLE_DEACTIVATE_ALL, | ||
| 776 | } ghostty_action_key_table_tag_e; | ||
| 777 | |||
| 778 | // apprt.action.KeyTable.CValue | ||
| 779 | typedef union { | ||
| 780 | struct { | ||
| 781 | const char *name; | ||
| 782 | size_t len; | ||
| 783 | } activate; | ||
| 784 | } ghostty_action_key_table_u; | ||
| 785 | |||
| 786 | // apprt.action.KeyTable.C | ||
| 787 | typedef struct { | ||
| 788 | ghostty_action_key_table_tag_e tag; | ||
| 789 | ghostty_action_key_table_u value; | ||
| 790 | } ghostty_action_key_table_s; | ||
| 791 | |||
| 792 | // apprt.action.ColorKind | ||
| 793 | typedef enum { | ||
| 794 | GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1, | ||
| 795 | GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2, | ||
| 796 | GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3, | ||
| 797 | } ghostty_action_color_kind_e; | ||
| 798 | |||
| 799 | // apprt.action.ColorChange | ||
| 800 | typedef struct { | ||
| 801 | ghostty_action_color_kind_e kind; | ||
| 802 | uint8_t r; | ||
| 803 | uint8_t g; | ||
| 804 | uint8_t b; | ||
| 805 | } ghostty_action_color_change_s; | ||
| 806 | |||
| 807 | // apprt.action.ConfigChange | ||
| 808 | typedef struct { | ||
| 809 | ghostty_config_t config; | ||
| 810 | } ghostty_action_config_change_s; | ||
| 811 | |||
| 812 | // apprt.action.ReloadConfig | ||
| 813 | typedef struct { | ||
| 814 | bool soft; | ||
| 815 | } ghostty_action_reload_config_s; | ||
| 816 | |||
| 817 | // apprt.action.OpenUrlKind | ||
| 818 | typedef enum { | ||
| 819 | GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, | ||
| 820 | GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, | ||
| 821 | GHOSTTY_ACTION_OPEN_URL_KIND_HTML, | ||
| 822 | } ghostty_action_open_url_kind_e; | ||
| 823 | |||
| 824 | // apprt.action.OpenUrl.C | ||
| 825 | typedef struct { | ||
| 826 | ghostty_action_open_url_kind_e kind; | ||
| 827 | const char* url; | ||
| 828 | uintptr_t len; | ||
| 829 | } ghostty_action_open_url_s; | ||
| 830 | |||
| 831 | // apprt.action.CloseTabMode | ||
| 832 | typedef enum { | ||
| 833 | GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, | ||
| 834 | GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, | ||
| 835 | GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, | ||
| 836 | } ghostty_action_close_tab_mode_e; | ||
| 837 | |||
| 838 | // apprt.surface.Message.ChildExited | ||
| 839 | typedef struct { | ||
| 840 | uint32_t exit_code; | ||
| 841 | uint64_t timetime_ms; | ||
| 842 | } ghostty_surface_message_childexited_s; | ||
| 843 | |||
| 844 | // terminal.osc.Command.ProgressReport.State | ||
| 845 | typedef enum { | ||
| 846 | GHOSTTY_PROGRESS_STATE_REMOVE, | ||
| 847 | GHOSTTY_PROGRESS_STATE_SET, | ||
| 848 | GHOSTTY_PROGRESS_STATE_ERROR, | ||
| 849 | GHOSTTY_PROGRESS_STATE_INDETERMINATE, | ||
| 850 | GHOSTTY_PROGRESS_STATE_PAUSE, | ||
| 851 | } ghostty_action_progress_report_state_e; | ||
| 852 | |||
| 853 | // terminal.osc.Command.ProgressReport.C | ||
| 854 | typedef struct { | ||
| 855 | ghostty_action_progress_report_state_e state; | ||
| 856 | // -1 if no progress was reported, otherwise 0-100 indicating percent | ||
| 857 | // completeness. | ||
| 858 | int8_t progress; | ||
| 859 | } ghostty_action_progress_report_s; | ||
| 860 | |||
| 861 | // apprt.action.CommandFinished.C | ||
| 862 | typedef struct { | ||
| 863 | // -1 if no exit code was reported, otherwise 0-255 | ||
| 864 | int16_t exit_code; | ||
| 865 | // number of nanoseconds that command was running for | ||
| 866 | uint64_t duration; | ||
| 867 | } ghostty_action_command_finished_s; | ||
| 868 | |||
| 869 | // apprt.action.StartSearch.C | ||
| 870 | typedef struct { | ||
| 871 | const char* needle; | ||
| 872 | } ghostty_action_start_search_s; | ||
| 873 | |||
| 874 | // apprt.action.SearchTotal | ||
| 875 | typedef struct { | ||
| 876 | ssize_t total; | ||
| 877 | } ghostty_action_search_total_s; | ||
| 878 | |||
| 879 | // apprt.action.SearchSelected | ||
| 880 | typedef struct { | ||
| 881 | ssize_t selected; | ||
| 882 | } ghostty_action_search_selected_s; | ||
| 883 | |||
| 884 | // terminal.Scrollbar | ||
| 885 | typedef struct { | ||
| 886 | uint64_t total; | ||
| 887 | uint64_t offset; | ||
| 888 | uint64_t len; | ||
| 889 | } ghostty_action_scrollbar_s; | ||
| 890 | |||
| 891 | // apprt.Action.Key | ||
| 892 | typedef enum { | ||
| 893 | GHOSTTY_ACTION_QUIT, | ||
| 894 | GHOSTTY_ACTION_NEW_WINDOW, | ||
| 895 | GHOSTTY_ACTION_NEW_TAB, | ||
| 896 | GHOSTTY_ACTION_CLOSE_TAB, | ||
| 897 | GHOSTTY_ACTION_NEW_SPLIT, | ||
| 898 | GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, | ||
| 899 | GHOSTTY_ACTION_TOGGLE_MAXIMIZE, | ||
| 900 | GHOSTTY_ACTION_TOGGLE_FULLSCREEN, | ||
| 901 | GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, | ||
| 902 | GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, | ||
| 903 | GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, | ||
| 904 | GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, | ||
| 905 | GHOSTTY_ACTION_TOGGLE_VISIBILITY, | ||
| 906 | GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY, | ||
| 907 | GHOSTTY_ACTION_MOVE_TAB, | ||
| 908 | GHOSTTY_ACTION_GOTO_TAB, | ||
| 909 | GHOSTTY_ACTION_GOTO_SPLIT, | ||
| 910 | GHOSTTY_ACTION_GOTO_WINDOW, | ||
| 911 | GHOSTTY_ACTION_RESIZE_SPLIT, | ||
| 912 | GHOSTTY_ACTION_EQUALIZE_SPLITS, | ||
| 913 | GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, | ||
| 914 | GHOSTTY_ACTION_PRESENT_TERMINAL, | ||
| 915 | GHOSTTY_ACTION_SIZE_LIMIT, | ||
| 916 | GHOSTTY_ACTION_RESET_WINDOW_SIZE, | ||
| 917 | GHOSTTY_ACTION_INITIAL_SIZE, | ||
| 918 | GHOSTTY_ACTION_CELL_SIZE, | ||
| 919 | GHOSTTY_ACTION_SCROLLBAR, | ||
| 920 | GHOSTTY_ACTION_RENDER, | ||
| 921 | GHOSTTY_ACTION_INSPECTOR, | ||
| 922 | GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, | ||
| 923 | GHOSTTY_ACTION_RENDER_INSPECTOR, | ||
| 924 | GHOSTTY_ACTION_DESKTOP_NOTIFICATION, | ||
| 925 | GHOSTTY_ACTION_SET_TITLE, | ||
| 926 | GHOSTTY_ACTION_SET_TAB_TITLE, | ||
| 927 | GHOSTTY_ACTION_PROMPT_TITLE, | ||
| 928 | GHOSTTY_ACTION_PWD, | ||
| 929 | GHOSTTY_ACTION_MOUSE_SHAPE, | ||
| 930 | GHOSTTY_ACTION_MOUSE_VISIBILITY, | ||
| 931 | GHOSTTY_ACTION_MOUSE_OVER_LINK, | ||
| 932 | GHOSTTY_ACTION_RENDERER_HEALTH, | ||
| 933 | GHOSTTY_ACTION_OPEN_CONFIG, | ||
| 934 | GHOSTTY_ACTION_QUIT_TIMER, | ||
| 935 | GHOSTTY_ACTION_FLOAT_WINDOW, | ||
| 936 | GHOSTTY_ACTION_SECURE_INPUT, | ||
| 937 | GHOSTTY_ACTION_KEY_SEQUENCE, | ||
| 938 | GHOSTTY_ACTION_KEY_TABLE, | ||
| 939 | GHOSTTY_ACTION_COLOR_CHANGE, | ||
| 940 | GHOSTTY_ACTION_RELOAD_CONFIG, | ||
| 941 | GHOSTTY_ACTION_CONFIG_CHANGE, | ||
| 942 | GHOSTTY_ACTION_CLOSE_WINDOW, | ||
| 943 | GHOSTTY_ACTION_RING_BELL, | ||
| 944 | GHOSTTY_ACTION_UNDO, | ||
| 945 | GHOSTTY_ACTION_REDO, | ||
| 946 | GHOSTTY_ACTION_CHECK_FOR_UPDATES, | ||
| 947 | GHOSTTY_ACTION_OPEN_URL, | ||
| 948 | GHOSTTY_ACTION_SHOW_CHILD_EXITED, | ||
| 949 | GHOSTTY_ACTION_PROGRESS_REPORT, | ||
| 950 | GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, | ||
| 951 | GHOSTTY_ACTION_COMMAND_FINISHED, | ||
| 952 | GHOSTTY_ACTION_START_SEARCH, | ||
| 953 | GHOSTTY_ACTION_END_SEARCH, | ||
| 954 | GHOSTTY_ACTION_SEARCH_TOTAL, | ||
| 955 | GHOSTTY_ACTION_SEARCH_SELECTED, | ||
| 956 | GHOSTTY_ACTION_READONLY, | ||
| 957 | GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, | ||
| 958 | } ghostty_action_tag_e; | ||
| 959 | |||
| 960 | typedef union { | ||
| 961 | ghostty_action_split_direction_e new_split; | ||
| 962 | ghostty_action_fullscreen_e toggle_fullscreen; | ||
| 963 | ghostty_action_move_tab_s move_tab; | ||
| 964 | ghostty_action_goto_tab_e goto_tab; | ||
| 965 | ghostty_action_goto_split_e goto_split; | ||
| 966 | ghostty_action_goto_window_e goto_window; | ||
| 967 | ghostty_action_resize_split_s resize_split; | ||
| 968 | ghostty_action_size_limit_s size_limit; | ||
| 969 | ghostty_action_initial_size_s initial_size; | ||
| 970 | ghostty_action_cell_size_s cell_size; | ||
| 971 | ghostty_action_scrollbar_s scrollbar; | ||
| 972 | ghostty_action_inspector_e inspector; | ||
| 973 | ghostty_action_desktop_notification_s desktop_notification; | ||
| 974 | ghostty_action_set_title_s set_title; | ||
| 975 | ghostty_action_set_title_s set_tab_title; | ||
| 976 | ghostty_action_prompt_title_e prompt_title; | ||
| 977 | ghostty_action_pwd_s pwd; | ||
| 978 | ghostty_action_mouse_shape_e mouse_shape; | ||
| 979 | ghostty_action_mouse_visibility_e mouse_visibility; | ||
| 980 | ghostty_action_mouse_over_link_s mouse_over_link; | ||
| 981 | ghostty_action_renderer_health_e renderer_health; | ||
| 982 | ghostty_action_quit_timer_e quit_timer; | ||
| 983 | ghostty_action_float_window_e float_window; | ||
| 984 | ghostty_action_secure_input_e secure_input; | ||
| 985 | ghostty_action_key_sequence_s key_sequence; | ||
| 986 | ghostty_action_key_table_s key_table; | ||
| 987 | ghostty_action_color_change_s color_change; | ||
| 988 | ghostty_action_reload_config_s reload_config; | ||
| 989 | ghostty_action_config_change_s config_change; | ||
| 990 | ghostty_action_open_url_s open_url; | ||
| 991 | ghostty_action_close_tab_mode_e close_tab_mode; | ||
| 992 | ghostty_surface_message_childexited_s child_exited; | ||
| 993 | ghostty_action_progress_report_s progress_report; | ||
| 994 | ghostty_action_command_finished_s command_finished; | ||
| 995 | ghostty_action_start_search_s start_search; | ||
| 996 | ghostty_action_search_total_s search_total; | ||
| 997 | ghostty_action_search_selected_s search_selected; | ||
| 998 | ghostty_action_readonly_e readonly; | ||
| 999 | } ghostty_action_u; | ||
| 1000 | |||
| 1001 | typedef struct { | ||
| 1002 | ghostty_action_tag_e tag; | ||
| 1003 | ghostty_action_u action; | ||
| 1004 | } ghostty_action_s; | ||
| 1005 | |||
| 1006 | typedef void (*ghostty_runtime_wakeup_cb)(void*); | ||
| 1007 | typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, | ||
| 1008 | ghostty_clipboard_e, | ||
| 1009 | void*); | ||
| 1010 | typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( | ||
| 1011 | void*, | ||
| 1012 | const char*, | ||
| 1013 | void*, | ||
| 1014 | ghostty_clipboard_request_e); | ||
| 1015 | typedef void (*ghostty_runtime_write_clipboard_cb)(void*, | ||
| 1016 | ghostty_clipboard_e, | ||
| 1017 | const ghostty_clipboard_content_s*, | ||
| 1018 | size_t, | ||
| 1019 | bool); | ||
| 1020 | typedef void (*ghostty_runtime_close_surface_cb)(void*, bool); | ||
| 1021 | typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t, | ||
| 1022 | ghostty_target_s, | ||
| 1023 | ghostty_action_s); | ||
| 1024 | |||
| 1025 | typedef struct { | ||
| 1026 | void* userdata; | ||
| 1027 | bool supports_selection_clipboard; | ||
| 1028 | ghostty_runtime_wakeup_cb wakeup_cb; | ||
| 1029 | ghostty_runtime_action_cb action_cb; | ||
| 1030 | ghostty_runtime_read_clipboard_cb read_clipboard_cb; | ||
| 1031 | ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb; | ||
| 1032 | ghostty_runtime_write_clipboard_cb write_clipboard_cb; | ||
| 1033 | ghostty_runtime_close_surface_cb close_surface_cb; | ||
| 1034 | } ghostty_runtime_config_s; | ||
| 1035 | |||
| 1036 | // apprt.ipc.Target.Key | ||
| 1037 | typedef enum { | ||
| 1038 | GHOSTTY_IPC_TARGET_CLASS, | ||
| 1039 | GHOSTTY_IPC_TARGET_DETECT, | ||
| 1040 | } ghostty_ipc_target_tag_e; | ||
| 1041 | |||
| 1042 | typedef union { | ||
| 1043 | char *klass; | ||
| 1044 | } ghostty_ipc_target_u; | ||
| 1045 | |||
| 1046 | typedef struct { | ||
| 1047 | ghostty_ipc_target_tag_e tag; | ||
| 1048 | ghostty_ipc_target_u target; | ||
| 1049 | } chostty_ipc_target_s; | ||
| 1050 | |||
| 1051 | // apprt.ipc.Action.NewWindow | ||
| 1052 | typedef struct { | ||
| 1053 | // This should be a null terminated list of strings. | ||
| 1054 | const char **arguments; | ||
| 1055 | } ghostty_ipc_action_new_window_s; | ||
| 1056 | |||
| 1057 | typedef union { | ||
| 1058 | ghostty_ipc_action_new_window_s new_window; | ||
| 1059 | } ghostty_ipc_action_u; | ||
| 1060 | |||
| 1061 | // apprt.ipc.Action.Key | ||
| 1062 | typedef enum { | ||
| 1063 | GHOSTTY_IPC_ACTION_NEW_WINDOW, | ||
| 1064 | } ghostty_ipc_action_tag_e; | ||
| 1065 | |||
| 1066 | //------------------------------------------------------------------- | ||
| 1067 | // Published API | ||
| 1068 | |||
| 1069 | GHOSTTY_API int ghostty_init(uintptr_t, char**); | ||
| 1070 | GHOSTTY_API void ghostty_cli_try_action(void); | ||
| 1071 | GHOSTTY_API ghostty_info_s ghostty_info(void); | ||
| 1072 | GHOSTTY_API const char* ghostty_translate(const char*); | ||
| 1073 | GHOSTTY_API void ghostty_string_free(ghostty_string_s); | ||
| 1074 | |||
| 1075 | GHOSTTY_API ghostty_config_t ghostty_config_new(); | ||
| 1076 | GHOSTTY_API void ghostty_config_free(ghostty_config_t); | ||
| 1077 | GHOSTTY_API ghostty_config_t ghostty_config_clone(ghostty_config_t); | ||
| 1078 | GHOSTTY_API void ghostty_config_load_cli_args(ghostty_config_t); | ||
| 1079 | GHOSTTY_API void ghostty_config_load_file(ghostty_config_t, const char*); | ||
| 1080 | GHOSTTY_API void ghostty_config_load_default_files(ghostty_config_t); | ||
| 1081 | GHOSTTY_API void ghostty_config_load_recursive_files(ghostty_config_t); | ||
| 1082 | GHOSTTY_API void ghostty_config_finalize(ghostty_config_t); | ||
| 1083 | GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t); | ||
| 1084 | GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, | ||
| 1085 | const char*, | ||
| 1086 | uintptr_t); | ||
| 1087 | GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t); | ||
| 1088 | GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); | ||
| 1089 | GHOSTTY_API ghostty_string_s ghostty_config_open_path(void); | ||
| 1090 | |||
| 1091 | GHOSTTY_API ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, | ||
| 1092 | ghostty_config_t); | ||
| 1093 | GHOSTTY_API void ghostty_app_free(ghostty_app_t); | ||
| 1094 | GHOSTTY_API void ghostty_app_tick(ghostty_app_t); | ||
| 1095 | GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t); | ||
| 1096 | GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool); | ||
| 1097 | GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); | ||
| 1098 | GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); | ||
| 1099 | GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t); | ||
| 1100 | GHOSTTY_API void ghostty_app_open_config(ghostty_app_t); | ||
| 1101 | GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); | ||
| 1102 | GHOSTTY_API bool ghostty_app_needs_confirm_quit(ghostty_app_t); | ||
| 1103 | GHOSTTY_API bool ghostty_app_has_global_keybinds(ghostty_app_t); | ||
| 1104 | GHOSTTY_API void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e); | ||
| 1105 | |||
| 1106 | GHOSTTY_API ghostty_surface_config_s ghostty_surface_config_new(); | ||
| 1107 | |||
| 1108 | GHOSTTY_API ghostty_surface_t ghostty_surface_new(ghostty_app_t, | ||
| 1109 | const ghostty_surface_config_s*); | ||
| 1110 | GHOSTTY_API void ghostty_surface_free(ghostty_surface_t); | ||
| 1111 | GHOSTTY_API void* ghostty_surface_userdata(ghostty_surface_t); | ||
| 1112 | GHOSTTY_API ghostty_app_t ghostty_surface_app(ghostty_surface_t); | ||
| 1113 | GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e); | ||
| 1114 | GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t); | ||
| 1115 | GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); | ||
| 1116 | GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t); | ||
| 1117 | GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t); | ||
| 1118 | GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t); | ||
| 1119 | GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, const uint8_t*, size_t); | ||
| 1120 | GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t, | ||
| 1121 | ghostty_surface_write_fn, | ||
| 1122 | void*); | ||
| 1123 | GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); | ||
| 1124 | GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool); | ||
| 1125 | GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool); | ||
| 1126 | GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); | ||
| 1127 | GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); | ||
| 1128 | GHOSTTY_API uint64_t ghostty_surface_foreground_pid(ghostty_surface_t); | ||
| 1129 | GHOSTTY_API ghostty_string_s ghostty_surface_tty_name(ghostty_surface_t); | ||
| 1130 | GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t, | ||
| 1131 | ghostty_color_scheme_e); | ||
| 1132 | GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, | ||
| 1133 | ghostty_input_mods_e); | ||
| 1134 | GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); | ||
| 1135 | GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t, | ||
| 1136 | ghostty_input_key_s, | ||
| 1137 | ghostty_binding_flags_e*); | ||
| 1138 | GHOSTTY_API void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); | ||
| 1139 | GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); | ||
| 1140 | GHOSTTY_API bool ghostty_surface_mouse_captured(ghostty_surface_t); | ||
| 1141 | GHOSTTY_API bool ghostty_surface_mouse_button(ghostty_surface_t, | ||
| 1142 | ghostty_input_mouse_state_e, | ||
| 1143 | ghostty_input_mouse_button_e, | ||
| 1144 | ghostty_input_mods_e); | ||
| 1145 | GHOSTTY_API void ghostty_surface_mouse_pos(ghostty_surface_t, | ||
| 1146 | double, | ||
| 1147 | double, | ||
| 1148 | ghostty_input_mods_e); | ||
| 1149 | GHOSTTY_API void ghostty_surface_mouse_scroll(ghostty_surface_t, | ||
| 1150 | double, | ||
| 1151 | double, | ||
| 1152 | ghostty_input_scroll_mods_t); | ||
| 1153 | GHOSTTY_API void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); | ||
| 1154 | GHOSTTY_API void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*); | ||
| 1155 | GHOSTTY_API void ghostty_surface_request_close(ghostty_surface_t); | ||
| 1156 | GHOSTTY_API void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); | ||
| 1157 | GHOSTTY_API void ghostty_surface_split_focus(ghostty_surface_t, | ||
| 1158 | ghostty_action_goto_split_e); | ||
| 1159 | GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, | ||
| 1160 | ghostty_action_resize_split_direction_e, | ||
| 1161 | uint16_t); | ||
| 1162 | GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); | ||
| 1163 | GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); | ||
| 1164 | GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, | ||
| 1165 | const char*, | ||
| 1166 | void*, | ||
| 1167 | bool); | ||
| 1168 | GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); | ||
| 1169 | GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); | ||
| 1170 | GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, | ||
| 1171 | ghostty_selection_s, | ||
| 1172 | ghostty_text_s*); | ||
| 1173 | GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*); | ||
| 1174 | |||
| 1175 | #ifdef __APPLE__ | ||
| 1176 | GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); | ||
| 1177 | GHOSTTY_API void* ghostty_surface_quicklook_font(ghostty_surface_t); | ||
| 1178 | GHOSTTY_API bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*); | ||
| 1179 | #endif | ||
| 1180 | |||
| 1181 | GHOSTTY_API ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); | ||
| 1182 | GHOSTTY_API void ghostty_inspector_free(ghostty_surface_t); | ||
| 1183 | GHOSTTY_API void ghostty_inspector_set_focus(ghostty_inspector_t, bool); | ||
| 1184 | GHOSTTY_API void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double); | ||
| 1185 | GHOSTTY_API void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t); | ||
| 1186 | GHOSTTY_API void ghostty_inspector_mouse_button(ghostty_inspector_t, | ||
| 1187 | ghostty_input_mouse_state_e, | ||
| 1188 | ghostty_input_mouse_button_e, | ||
| 1189 | ghostty_input_mods_e); | ||
| 1190 | GHOSTTY_API void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double); | ||
| 1191 | GHOSTTY_API void ghostty_inspector_mouse_scroll(ghostty_inspector_t, | ||
| 1192 | double, | ||
| 1193 | double, | ||
| 1194 | ghostty_input_scroll_mods_t); | ||
| 1195 | GHOSTTY_API void ghostty_inspector_key(ghostty_inspector_t, | ||
| 1196 | ghostty_input_action_e, | ||
| 1197 | ghostty_input_key_e, | ||
| 1198 | ghostty_input_mods_e); | ||
| 1199 | GHOSTTY_API void ghostty_inspector_text(ghostty_inspector_t, const char*); | ||
| 1200 | |||
| 1201 | #ifdef __APPLE__ | ||
| 1202 | GHOSTTY_API bool ghostty_inspector_metal_init(ghostty_inspector_t, void*); | ||
| 1203 | GHOSTTY_API void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*); | ||
| 1204 | GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); | ||
| 1205 | #endif | ||
| 1206 | |||
| 1207 | // APIs I'd like to get rid of eventually but are still needed for now. | ||
| 1208 | // Don't use these unless you know what you're doing. | ||
| 1209 | GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*); | ||
| 1210 | |||
| 1211 | // Benchmark API, if available. | ||
| 1212 | GHOSTTY_API bool ghostty_benchmark_cli(const char*, const char*); | ||
| 1213 | |||
| 1214 | #ifdef __cplusplus | ||
| 1215 | } | ||
| 1216 | #endif | ||
| 1217 | |||
| 1218 | #endif /* GHOSTTY_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h created+87| ... | @@ -0,0 +1,87 @@ | ||
| 1 | /** | ||
| 2 | * @file vt.h | ||
| 3 | * | ||
| 4 | * libghostty-vt - Virtual terminal emulator library | ||
| 5 | * | ||
| 6 | * This library provides functionality for parsing and handling terminal | ||
| 7 | * escape sequences as well as maintaining terminal state such as styles, | ||
| 8 | * cursor position, screen, scrollback, and more. | ||
| 9 | * | ||
| 10 | * WARNING: This is an incomplete, work-in-progress API. It is not yet | ||
| 11 | * stable and is definitely going to change. | ||
| 12 | */ | ||
| 13 | |||
| 14 | /** | ||
| 15 | * @mainpage libghostty-vt - Virtual Terminal Emulator Library | ||
| 16 | * | ||
| 17 | * libghostty-vt is a C library which implements a modern terminal emulator, | ||
| 18 | * extracted from the [Ghostty](https://ghostty.org) terminal emulator. | ||
| 19 | * | ||
| 20 | * libghostty-vt contains the logic for handling the core parts of a terminal | ||
| 21 | * emulator: parsing terminal escape sequences, maintaining terminal state, | ||
| 22 | * encoding input events, etc. It can handle scrollback, line wrapping, | ||
| 23 | * reflow on resize, and more. | ||
| 24 | * | ||
| 25 | * @warning This library is currently in development and the API is not yet stable. | ||
| 26 | * Breaking changes are expected in future versions. Use with caution in production code. | ||
| 27 | * | ||
| 28 | * @section groups_sec API Reference | ||
| 29 | * | ||
| 30 | * The API is organized into the following groups: | ||
| 31 | * - @ref key "Key Encoding" - Encode key events into terminal sequences | ||
| 32 | * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences | ||
| 33 | * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences | ||
| 34 | * - @ref paste "Paste Utilities" - Validate paste data safety | ||
| 35 | * - @ref allocator "Memory Management" - Memory management and custom allocators | ||
| 36 | * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions | ||
| 37 | * | ||
| 38 | * @section examples_sec Examples | ||
| 39 | * | ||
| 40 | * Complete working examples: | ||
| 41 | * - @ref c-vt/src/main.c - OSC parser example | ||
| 42 | * - @ref c-vt-key-encode/src/main.c - Key encoding example | ||
| 43 | * - @ref c-vt-paste/src/main.c - Paste safety check example | ||
| 44 | * - @ref c-vt-sgr/src/main.c - SGR parser example | ||
| 45 | * | ||
| 46 | */ | ||
| 47 | |||
| 48 | /** @example c-vt/src/main.c | ||
| 49 | * This example demonstrates how to use the OSC parser to parse an OSC sequence, | ||
| 50 | * extract command information, and retrieve command-specific data like window titles. | ||
| 51 | */ | ||
| 52 | |||
| 53 | /** @example c-vt-key-encode/src/main.c | ||
| 54 | * This example demonstrates how to use the key encoder to convert key events | ||
| 55 | * into terminal escape sequences using the Kitty keyboard protocol. | ||
| 56 | */ | ||
| 57 | |||
| 58 | /** @example c-vt-paste/src/main.c | ||
| 59 | * This example demonstrates how to use the paste utilities to check if | ||
| 60 | * paste data is safe before sending it to the terminal. | ||
| 61 | */ | ||
| 62 | |||
| 63 | /** @example c-vt-sgr/src/main.c | ||
| 64 | * This example demonstrates how to use the SGR parser to parse terminal | ||
| 65 | * styling sequences and extract text attributes like colors and underline styles. | ||
| 66 | */ | ||
| 67 | |||
| 68 | #ifndef GHOSTTY_VT_H | ||
| 69 | #define GHOSTTY_VT_H | ||
| 70 | |||
| 71 | #ifdef __cplusplus | ||
| 72 | extern "C" { | ||
| 73 | #endif | ||
| 74 | |||
| 75 | #include <ghostty/vt/result.h> | ||
| 76 | #include <ghostty/vt/allocator.h> | ||
| 77 | #include <ghostty/vt/osc.h> | ||
| 78 | #include <ghostty/vt/sgr.h> | ||
| 79 | #include <ghostty/vt/key.h> | ||
| 80 | #include <ghostty/vt/paste.h> | ||
| 81 | #include <ghostty/vt/wasm.h> | ||
| 82 | |||
| 83 | #ifdef __cplusplus | ||
| 84 | } | ||
| 85 | #endif | ||
| 86 | |||
| 87 | #endif /* GHOSTTY_VT_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h created+196| ... | @@ -0,0 +1,196 @@ | ||
| 1 | /** | ||
| 2 | * @file allocator.h | ||
| 3 | * | ||
| 4 | * Memory management interface for libghostty-vt. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_ALLOCATOR_H | ||
| 8 | #define GHOSTTY_VT_ALLOCATOR_H | ||
| 9 | |||
| 10 | #include <stdbool.h> | ||
| 11 | #include <stddef.h> | ||
| 12 | #include <stdint.h> | ||
| 13 | |||
| 14 | /** @defgroup allocator Memory Management | ||
| 15 | * | ||
| 16 | * libghostty-vt does require memory allocation for various operations, | ||
| 17 | * but is resilient to allocation failures and will gracefully handle | ||
| 18 | * out-of-memory situations by returning error codes. | ||
| 19 | * | ||
| 20 | * The exact memory management semantics are documented in the relevant | ||
| 21 | * functions and data structures. | ||
| 22 | * | ||
| 23 | * libghostty-vt uses explicit memory allocation via an allocator | ||
| 24 | * interface provided by GhosttyAllocator. The interface is based on the | ||
| 25 | * [Zig](https://ziglang.org) allocator interface, since this has been | ||
| 26 | * shown to be a flexible and powerful interface in practice and enables | ||
| 27 | * a wide variety of allocation strategies. | ||
| 28 | * | ||
| 29 | * **For the common case, you can pass NULL as the allocator for any | ||
| 30 | * function that accepts one,** and libghostty will use a default allocator. | ||
| 31 | * The default allocator will be libc malloc/free if libc is linked. | ||
| 32 | * Otherwise, a custom allocator is used (currently Zig's SMP allocator) | ||
| 33 | * that doesn't require any external dependencies. | ||
| 34 | * | ||
| 35 | * ## Basic Usage | ||
| 36 | * | ||
| 37 | * For simple use cases, you can ignore this interface entirely by passing NULL | ||
| 38 | * as the allocator parameter to functions that accept one. This will use the | ||
| 39 | * default allocator (typically libc malloc/free, if libc is linked, but | ||
| 40 | * we provide our own default allocator if libc isn't linked). | ||
| 41 | * | ||
| 42 | * To use a custom allocator: | ||
| 43 | * 1. Implement the GhosttyAllocatorVtable function pointers | ||
| 44 | * 2. Create a GhosttyAllocator struct with your vtable and context | ||
| 45 | * 3. Pass the allocator to functions that accept one | ||
| 46 | * | ||
| 47 | * @{ | ||
| 48 | */ | ||
| 49 | |||
| 50 | /** | ||
| 51 | * Function table for custom memory allocator operations. | ||
| 52 | * | ||
| 53 | * This vtable defines the interface for a custom memory allocator. All | ||
| 54 | * function pointers must be valid and non-NULL. | ||
| 55 | * | ||
| 56 | * @ingroup allocator | ||
| 57 | * | ||
| 58 | * If you're not going to use a custom allocator, you can ignore all of | ||
| 59 | * this. All functions that take an allocator pointer allow NULL to use a | ||
| 60 | * default allocator. | ||
| 61 | * | ||
| 62 | * The interface is based on the Zig allocator interface. I'll say up front | ||
| 63 | * that it is easy to look at this interface and think "wow, this is really | ||
| 64 | * overcomplicated". The reason for this complexity is well thought out by | ||
| 65 | * the Zig folks, and it enables a diverse set of allocation strategies | ||
| 66 | * as shown by the Zig ecosystem. As a consolation, please note that many | ||
| 67 | * of the arguments are only needed for advanced use cases and can be | ||
| 68 | * safely ignored in simple implementations. For example, if you look at | ||
| 69 | * the Zig implementation of the libc allocator in `lib/std/heap.zig` | ||
| 70 | * (search for CAllocator), you'll see it is very simple. | ||
| 71 | * | ||
| 72 | * We chose to align with the Zig allocator interface because: | ||
| 73 | * | ||
| 74 | * 1. It is a proven interface that serves a wide variety of use cases | ||
| 75 | * in the real world via the Zig ecosystem. It's shown to work. | ||
| 76 | * | ||
| 77 | * 2. Our core implementation itself is Zig, and this lets us very | ||
| 78 | * cheaply and easily convert between C and Zig allocators. | ||
| 79 | * | ||
| 80 | * NOTE(mitchellh): In the future, we can have default implementations of | ||
| 81 | * resize/remap and allow those to be null. | ||
| 82 | */ | ||
| 83 | typedef struct { | ||
| 84 | /** | ||
| 85 | * Return a pointer to `len` bytes with specified `alignment`, or return | ||
| 86 | * `NULL` indicating the allocation failed. | ||
| 87 | * | ||
| 88 | * @param ctx The allocator context | ||
| 89 | * @param len Number of bytes to allocate | ||
| 90 | * @param alignment Required alignment for the allocation. Guaranteed to | ||
| 91 | * be a power of two between 1 and 16 inclusive. | ||
| 92 | * @param ret_addr First return address of the allocation call stack (0 if not provided) | ||
| 93 | * @return Pointer to allocated memory, or NULL if allocation failed | ||
| 94 | */ | ||
| 95 | void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); | ||
| 96 | |||
| 97 | /** | ||
| 98 | * Attempt to expand or shrink memory in place. | ||
| 99 | * | ||
| 100 | * `memory_len` must equal the length requested from the most recent | ||
| 101 | * successful call to `alloc`, `resize`, or `remap`. `alignment` must | ||
| 102 | * equal the same value that was passed as the `alignment` parameter to | ||
| 103 | * the original `alloc` call. | ||
| 104 | * | ||
| 105 | * `new_len` must be greater than zero. | ||
| 106 | * | ||
| 107 | * @param ctx The allocator context | ||
| 108 | * @param memory Pointer to the memory block to resize | ||
| 109 | * @param memory_len Current size of the memory block | ||
| 110 | * @param alignment Alignment (must match original allocation) | ||
| 111 | * @param new_len New requested size | ||
| 112 | * @param ret_addr First return address of the allocation call stack (0 if not provided) | ||
| 113 | * @return true if resize was successful in-place, false if relocation would be required | ||
| 114 | */ | ||
| 115 | bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); | ||
| 116 | |||
| 117 | /** | ||
| 118 | * Attempt to expand or shrink memory, allowing relocation. | ||
| 119 | * | ||
| 120 | * `memory_len` must equal the length requested from the most recent | ||
| 121 | * successful call to `alloc`, `resize`, or `remap`. `alignment` must | ||
| 122 | * equal the same value that was passed as the `alignment` parameter to | ||
| 123 | * the original `alloc` call. | ||
| 124 | * | ||
| 125 | * A non-`NULL` return value indicates the resize was successful. The | ||
| 126 | * allocation may have same address, or may have been relocated. In either | ||
| 127 | * case, the allocation now has size of `new_len`. A `NULL` return value | ||
| 128 | * indicates that the resize would be equivalent to allocating new memory, | ||
| 129 | * copying the bytes from the old memory, and then freeing the old memory. | ||
| 130 | * In such case, it is more efficient for the caller to perform the copy. | ||
| 131 | * | ||
| 132 | * `new_len` must be greater than zero. | ||
| 133 | * | ||
| 134 | * @param ctx The allocator context | ||
| 135 | * @param memory Pointer to the memory block to remap | ||
| 136 | * @param memory_len Current size of the memory block | ||
| 137 | * @param alignment Alignment (must match original allocation) | ||
| 138 | * @param new_len New requested size | ||
| 139 | * @param ret_addr First return address of the allocation call stack (0 if not provided) | ||
| 140 | * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed | ||
| 141 | */ | ||
| 142 | void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); | ||
| 143 | |||
| 144 | /** | ||
| 145 | * Free and invalidate a region of memory. | ||
| 146 | * | ||
| 147 | * `memory_len` must equal the length requested from the most recent | ||
| 148 | * successful call to `alloc`, `resize`, or `remap`. `alignment` must | ||
| 149 | * equal the same value that was passed as the `alignment` parameter to | ||
| 150 | * the original `alloc` call. | ||
| 151 | * | ||
| 152 | * @param ctx The allocator context | ||
| 153 | * @param memory Pointer to the memory block to free | ||
| 154 | * @param memory_len Size of the memory block | ||
| 155 | * @param alignment Alignment (must match original allocation) | ||
| 156 | * @param ret_addr First return address of the allocation call stack (0 if not provided) | ||
| 157 | */ | ||
| 158 | void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); | ||
| 159 | } GhosttyAllocatorVtable; | ||
| 160 | |||
| 161 | /** | ||
| 162 | * Custom memory allocator. | ||
| 163 | * | ||
| 164 | * For functions that take an allocator pointer, a NULL pointer indicates | ||
| 165 | * that the default allocator should be used. The default allocator will | ||
| 166 | * be libc malloc/free if we're linking to libc. If libc isn't linked, | ||
| 167 | * a custom allocator is used (currently Zig's SMP allocator). | ||
| 168 | * | ||
| 169 | * @ingroup allocator | ||
| 170 | * | ||
| 171 | * Usage example: | ||
| 172 | * @code | ||
| 173 | * GhosttyAllocator allocator = { | ||
| 174 | * .vtable = &my_allocator_vtable, | ||
| 175 | * .ctx = my_allocator_state | ||
| 176 | * }; | ||
| 177 | * @endcode | ||
| 178 | */ | ||
| 179 | typedef struct GhosttyAllocator { | ||
| 180 | /** | ||
| 181 | * Opaque context pointer passed to all vtable functions. | ||
| 182 | * This allows the allocator implementation to maintain state | ||
| 183 | * or reference external resources needed for memory management. | ||
| 184 | */ | ||
| 185 | void *ctx; | ||
| 186 | |||
| 187 | /** | ||
| 188 | * Pointer to the allocator's vtable containing function pointers | ||
| 189 | * for memory operations (alloc, resize, remap, free). | ||
| 190 | */ | ||
| 191 | const GhosttyAllocatorVtable *vtable; | ||
| 192 | } GhosttyAllocator; | ||
| 193 | |||
| 194 | /** @} */ | ||
| 195 | |||
| 196 | #endif /* GHOSTTY_VT_ALLOCATOR_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h created+96| ... | @@ -0,0 +1,96 @@ | ||
| 1 | /** | ||
| 2 | * @file color.h | ||
| 3 | * | ||
| 4 | * Color types and utilities. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_COLOR_H | ||
| 8 | #define GHOSTTY_VT_COLOR_H | ||
| 9 | |||
| 10 | #include <stdint.h> | ||
| 11 | |||
| 12 | #ifdef __cplusplus | ||
| 13 | extern "C" { | ||
| 14 | #endif | ||
| 15 | |||
| 16 | /** | ||
| 17 | * RGB color value. | ||
| 18 | * | ||
| 19 | * @ingroup sgr | ||
| 20 | */ | ||
| 21 | typedef struct { | ||
| 22 | uint8_t r; /**< Red component (0-255) */ | ||
| 23 | uint8_t g; /**< Green component (0-255) */ | ||
| 24 | uint8_t b; /**< Blue component (0-255) */ | ||
| 25 | } GhosttyColorRgb; | ||
| 26 | |||
| 27 | /** | ||
| 28 | * Palette color index (0-255). | ||
| 29 | * | ||
| 30 | * @ingroup sgr | ||
| 31 | */ | ||
| 32 | typedef uint8_t GhosttyColorPaletteIndex; | ||
| 33 | |||
| 34 | /** @addtogroup sgr | ||
| 35 | * @{ | ||
| 36 | */ | ||
| 37 | |||
| 38 | /** Black color (0) @ingroup sgr */ | ||
| 39 | #define GHOSTTY_COLOR_NAMED_BLACK 0 | ||
| 40 | /** Red color (1) @ingroup sgr */ | ||
| 41 | #define GHOSTTY_COLOR_NAMED_RED 1 | ||
| 42 | /** Green color (2) @ingroup sgr */ | ||
| 43 | #define GHOSTTY_COLOR_NAMED_GREEN 2 | ||
| 44 | /** Yellow color (3) @ingroup sgr */ | ||
| 45 | #define GHOSTTY_COLOR_NAMED_YELLOW 3 | ||
| 46 | /** Blue color (4) @ingroup sgr */ | ||
| 47 | #define GHOSTTY_COLOR_NAMED_BLUE 4 | ||
| 48 | /** Magenta color (5) @ingroup sgr */ | ||
| 49 | #define GHOSTTY_COLOR_NAMED_MAGENTA 5 | ||
| 50 | /** Cyan color (6) @ingroup sgr */ | ||
| 51 | #define GHOSTTY_COLOR_NAMED_CYAN 6 | ||
| 52 | /** White color (7) @ingroup sgr */ | ||
| 53 | #define GHOSTTY_COLOR_NAMED_WHITE 7 | ||
| 54 | /** Bright black color (8) @ingroup sgr */ | ||
| 55 | #define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 | ||
| 56 | /** Bright red color (9) @ingroup sgr */ | ||
| 57 | #define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 | ||
| 58 | /** Bright green color (10) @ingroup sgr */ | ||
| 59 | #define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 | ||
| 60 | /** Bright yellow color (11) @ingroup sgr */ | ||
| 61 | #define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 | ||
| 62 | /** Bright blue color (12) @ingroup sgr */ | ||
| 63 | #define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 | ||
| 64 | /** Bright magenta color (13) @ingroup sgr */ | ||
| 65 | #define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 | ||
| 66 | /** Bright cyan color (14) @ingroup sgr */ | ||
| 67 | #define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 | ||
| 68 | /** Bright white color (15) @ingroup sgr */ | ||
| 69 | #define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 | ||
| 70 | |||
| 71 | /** @} */ | ||
| 72 | |||
| 73 | /** | ||
| 74 | * Get the RGB color components. | ||
| 75 | * | ||
| 76 | * This function extracts the individual red, green, and blue components | ||
| 77 | * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments | ||
| 78 | * where accessing struct fields directly is difficult. | ||
| 79 | * | ||
| 80 | * @param color The RGB color value | ||
| 81 | * @param r Pointer to store the red component (0-255) | ||
| 82 | * @param g Pointer to store the green component (0-255) | ||
| 83 | * @param b Pointer to store the blue component (0-255) | ||
| 84 | * | ||
| 85 | * @ingroup sgr | ||
| 86 | */ | ||
| 87 | void ghostty_color_rgb_get(GhosttyColorRgb color, | ||
| 88 | uint8_t* r, | ||
| 89 | uint8_t* g, | ||
| 90 | uint8_t* b); | ||
| 91 | |||
| 92 | #ifdef __cplusplus | ||
| 93 | } | ||
| 94 | #endif | ||
| 95 | |||
| 96 | #endif /* GHOSTTY_VT_COLOR_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h created+80| ... | @@ -0,0 +1,80 @@ | ||
| 1 | /** | ||
| 2 | * @file key.h | ||
| 3 | * | ||
| 4 | * Key encoding module - encode key events into terminal escape sequences. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_KEY_H | ||
| 8 | #define GHOSTTY_VT_KEY_H | ||
| 9 | |||
| 10 | /** @defgroup key Key Encoding | ||
| 11 | * | ||
| 12 | * Utilities for encoding key events into terminal escape sequences, | ||
| 13 | * supporting both legacy encoding as well as Kitty Keyboard Protocol. | ||
| 14 | * | ||
| 15 | * ## Basic Usage | ||
| 16 | * | ||
| 17 | * 1. Create an encoder instance with ghostty_key_encoder_new() | ||
| 18 | * 2. Configure encoder options with ghostty_key_encoder_setopt(). | ||
| 19 | * 3. For each key event: | ||
| 20 | * - Create a key event with ghostty_key_event_new() | ||
| 21 | * - Set event properties (action, key, modifiers, etc.) | ||
| 22 | * - Encode with ghostty_key_encoder_encode() | ||
| 23 | * - Free the event with ghostty_key_event_free() | ||
| 24 | * - Note: You can also reuse the same key event multiple times by | ||
| 25 | * changing its properties. | ||
| 26 | * 4. Free the encoder with ghostty_key_encoder_free() when done | ||
| 27 | * | ||
| 28 | * ## Example | ||
| 29 | * | ||
| 30 | * @code{.c} | ||
| 31 | * #include <assert.h> | ||
| 32 | * #include <stdio.h> | ||
| 33 | * #include <ghostty/vt.h> | ||
| 34 | * | ||
| 35 | * int main() { | ||
| 36 | * // Create encoder | ||
| 37 | * GhosttyKeyEncoder encoder; | ||
| 38 | * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); | ||
| 39 | * assert(result == GHOSTTY_SUCCESS); | ||
| 40 | * | ||
| 41 | * // Enable Kitty keyboard protocol with all features | ||
| 42 | * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, | ||
| 43 | * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); | ||
| 44 | * | ||
| 45 | * // Create and configure key event for Ctrl+C press | ||
| 46 | * GhosttyKeyEvent event; | ||
| 47 | * result = ghostty_key_event_new(NULL, &event); | ||
| 48 | * assert(result == GHOSTTY_SUCCESS); | ||
| 49 | * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); | ||
| 50 | * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); | ||
| 51 | * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); | ||
| 52 | * | ||
| 53 | * // Encode the key event | ||
| 54 | * char buf[128]; | ||
| 55 | * size_t written = 0; | ||
| 56 | * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); | ||
| 57 | * assert(result == GHOSTTY_SUCCESS); | ||
| 58 | * | ||
| 59 | * // Use the encoded sequence (e.g., write to terminal) | ||
| 60 | * fwrite(buf, 1, written, stdout); | ||
| 61 | * | ||
| 62 | * // Cleanup | ||
| 63 | * ghostty_key_event_free(event); | ||
| 64 | * ghostty_key_encoder_free(encoder); | ||
| 65 | * return 0; | ||
| 66 | * } | ||
| 67 | * @endcode | ||
| 68 | * | ||
| 69 | * For a complete working example, see example/c-vt-key-encode in the | ||
| 70 | * repository. | ||
| 71 | * | ||
| 72 | * @{ | ||
| 73 | */ | ||
| 74 | |||
| 75 | #include <ghostty/vt/key/event.h> | ||
| 76 | #include <ghostty/vt/key/encoder.h> | ||
| 77 | |||
| 78 | /** @} */ | ||
| 79 | |||
| 80 | #endif /* GHOSTTY_VT_KEY_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h created+221| ... | @@ -0,0 +1,221 @@ | ||
| 1 | /** | ||
| 2 | * @file encoder.h | ||
| 3 | * | ||
| 4 | * Key event encoding to terminal escape sequences. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_KEY_ENCODER_H | ||
| 8 | #define GHOSTTY_VT_KEY_ENCODER_H | ||
| 9 | |||
| 10 | #include <stddef.h> | ||
| 11 | #include <stdint.h> | ||
| 12 | #include <ghostty/vt/result.h> | ||
| 13 | #include <ghostty/vt/allocator.h> | ||
| 14 | #include <ghostty/vt/key/event.h> | ||
| 15 | |||
| 16 | /** | ||
| 17 | * Opaque handle to a key encoder instance. | ||
| 18 | * | ||
| 19 | * This handle represents a key encoder that converts key events into terminal | ||
| 20 | * escape sequences. | ||
| 21 | * | ||
| 22 | * @ingroup key | ||
| 23 | */ | ||
| 24 | typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; | ||
| 25 | |||
| 26 | /** | ||
| 27 | * Kitty keyboard protocol flags. | ||
| 28 | * | ||
| 29 | * Bitflags representing the various modes of the Kitty keyboard protocol. | ||
| 30 | * These can be combined using bitwise OR operations. Valid values all | ||
| 31 | * start with `GHOSTTY_KITTY_KEY_`. | ||
| 32 | * | ||
| 33 | * @ingroup key | ||
| 34 | */ | ||
| 35 | typedef uint8_t GhosttyKittyKeyFlags; | ||
| 36 | |||
| 37 | /** Kitty keyboard protocol disabled (all flags off) */ | ||
| 38 | #define GHOSTTY_KITTY_KEY_DISABLED 0 | ||
| 39 | |||
| 40 | /** Disambiguate escape codes */ | ||
| 41 | #define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) | ||
| 42 | |||
| 43 | /** Report key press and release events */ | ||
| 44 | #define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) | ||
| 45 | |||
| 46 | /** Report alternate key codes */ | ||
| 47 | #define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) | ||
| 48 | |||
| 49 | /** Report all key events including those normally handled by the terminal */ | ||
| 50 | #define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) | ||
| 51 | |||
| 52 | /** Report associated text with key events */ | ||
| 53 | #define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) | ||
| 54 | |||
| 55 | /** All Kitty keyboard protocol flags enabled */ | ||
| 56 | #define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) | ||
| 57 | |||
| 58 | /** | ||
| 59 | * macOS option key behavior. | ||
| 60 | * | ||
| 61 | * Determines whether the "option" key on macOS is treated as "alt" or not. | ||
| 62 | * See the Ghostty `macos-option-as-alt` configuration option for more details. | ||
| 63 | * | ||
| 64 | * @ingroup key | ||
| 65 | */ | ||
| 66 | typedef enum { | ||
| 67 | /** Option key is not treated as alt */ | ||
| 68 | GHOSTTY_OPTION_AS_ALT_FALSE = 0, | ||
| 69 | /** Option key is treated as alt */ | ||
| 70 | GHOSTTY_OPTION_AS_ALT_TRUE = 1, | ||
| 71 | /** Only left option key is treated as alt */ | ||
| 72 | GHOSTTY_OPTION_AS_ALT_LEFT = 2, | ||
| 73 | /** Only right option key is treated as alt */ | ||
| 74 | GHOSTTY_OPTION_AS_ALT_RIGHT = 3, | ||
| 75 | } GhosttyOptionAsAlt; | ||
| 76 | |||
| 77 | /** | ||
| 78 | * Key encoder option identifiers. | ||
| 79 | * | ||
| 80 | * These values are used with ghostty_key_encoder_setopt() to configure | ||
| 81 | * the behavior of the key encoder. | ||
| 82 | * | ||
| 83 | * @ingroup key | ||
| 84 | */ | ||
| 85 | typedef enum { | ||
| 86 | /** Terminal DEC mode 1: cursor key application mode (value: bool) */ | ||
| 87 | GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, | ||
| 88 | |||
| 89 | /** Terminal DEC mode 66: keypad key application mode (value: bool) */ | ||
| 90 | GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, | ||
| 91 | |||
| 92 | /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ | ||
| 93 | GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, | ||
| 94 | |||
| 95 | /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ | ||
| 96 | GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, | ||
| 97 | |||
| 98 | /** xterm modifyOtherKeys mode 2 (value: bool) */ | ||
| 99 | GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, | ||
| 100 | |||
| 101 | /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ | ||
| 102 | GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, | ||
| 103 | |||
| 104 | /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ | ||
| 105 | GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, | ||
| 106 | } GhosttyKeyEncoderOption; | ||
| 107 | |||
| 108 | /** | ||
| 109 | * Create a new key encoder instance. | ||
| 110 | * | ||
| 111 | * Creates a new key encoder with default options. The encoder can be configured | ||
| 112 | * using ghostty_key_encoder_setopt() and must be freed using | ||
| 113 | * ghostty_key_encoder_free() when no longer needed. | ||
| 114 | * | ||
| 115 | * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator | ||
| 116 | * @param encoder Pointer to store the created encoder handle | ||
| 117 | * @return GHOSTTY_SUCCESS on success, or an error code on failure | ||
| 118 | * | ||
| 119 | * @ingroup key | ||
| 120 | */ | ||
| 121 | GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); | ||
| 122 | |||
| 123 | /** | ||
| 124 | * Free a key encoder instance. | ||
| 125 | * | ||
| 126 | * Releases all resources associated with the key encoder. After this call, | ||
| 127 | * the encoder handle becomes invalid and must not be used. | ||
| 128 | * | ||
| 129 | * @param encoder The encoder handle to free (may be NULL) | ||
| 130 | * | ||
| 131 | * @ingroup key | ||
| 132 | */ | ||
| 133 | void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); | ||
| 134 | |||
| 135 | /** | ||
| 136 | * Set an option on the key encoder. | ||
| 137 | * | ||
| 138 | * Configures the behavior of the key encoder. Options control various aspects | ||
| 139 | * of encoding such as terminal modes (cursor key application mode, keypad mode), | ||
| 140 | * protocol selection (Kitty keyboard protocol flags), and platform-specific | ||
| 141 | * behaviors (macOS option-as-alt). | ||
| 142 | * | ||
| 143 | * A null pointer value does nothing. It does not reset the value to the | ||
| 144 | * default. The setopt call will do nothing. | ||
| 145 | * | ||
| 146 | * @param encoder The encoder handle, must not be NULL | ||
| 147 | * @param option The option to set | ||
| 148 | * @param value Pointer to the value to set (type depends on the option) | ||
| 149 | * | ||
| 150 | * @ingroup key | ||
| 151 | */ | ||
| 152 | void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); | ||
| 153 | |||
| 154 | /** | ||
| 155 | * Encode a key event into a terminal escape sequence. | ||
| 156 | * | ||
| 157 | * Converts a key event into the appropriate terminal escape sequence based on | ||
| 158 | * the encoder's current options. The sequence is written to the provided buffer. | ||
| 159 | * | ||
| 160 | * Not all key events produce output. For example, unmodified modifier keys | ||
| 161 | * typically don't generate escape sequences. Check the out_len parameter to | ||
| 162 | * determine if any data was written. | ||
| 163 | * | ||
| 164 | * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY | ||
| 165 | * and out_len will contain the required buffer size. The caller can then | ||
| 166 | * allocate a larger buffer and call the function again. | ||
| 167 | * | ||
| 168 | * @param encoder The encoder handle, must not be NULL | ||
| 169 | * @param event The key event to encode, must not be NULL | ||
| 170 | * @param out_buf Buffer to write the encoded sequence to | ||
| 171 | * @param out_buf_size Size of the output buffer in bytes | ||
| 172 | * @param out_len Pointer to store the number of bytes written (may be NULL) | ||
| 173 | * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code | ||
| 174 | * | ||
| 175 | * ## Example: Calculate required buffer size | ||
| 176 | * | ||
| 177 | * @code{.c} | ||
| 178 | * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) | ||
| 179 | * size_t required = 0; | ||
| 180 | * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); | ||
| 181 | * assert(result == GHOSTTY_OUT_OF_MEMORY); | ||
| 182 | * | ||
| 183 | * // Allocate buffer of required size | ||
| 184 | * char *buf = malloc(required); | ||
| 185 | * | ||
| 186 | * // Encode with properly sized buffer | ||
| 187 | * size_t written = 0; | ||
| 188 | * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); | ||
| 189 | * assert(result == GHOSTTY_SUCCESS); | ||
| 190 | * | ||
| 191 | * // Use the encoded sequence... | ||
| 192 | * | ||
| 193 | * free(buf); | ||
| 194 | * @endcode | ||
| 195 | * | ||
| 196 | * ## Example: Direct encoding with static buffer | ||
| 197 | * | ||
| 198 | * @code{.c} | ||
| 199 | * // Most escape sequences are short, so a static buffer often suffices | ||
| 200 | * char buf[128]; | ||
| 201 | * size_t written = 0; | ||
| 202 | * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); | ||
| 203 | * | ||
| 204 | * if (result == GHOSTTY_SUCCESS) { | ||
| 205 | * // Write the encoded sequence to the terminal | ||
| 206 | * write(pty_fd, buf, written); | ||
| 207 | * } else if (result == GHOSTTY_OUT_OF_MEMORY) { | ||
| 208 | * // Buffer too small, written contains required size | ||
| 209 | * char *dynamic_buf = malloc(written); | ||
| 210 | * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); | ||
| 211 | * assert(result == GHOSTTY_SUCCESS); | ||
| 212 | * write(pty_fd, dynamic_buf, written); | ||
| 213 | * free(dynamic_buf); | ||
| 214 | * } | ||
| 215 | * @endcode | ||
| 216 | * | ||
| 217 | * @ingroup key | ||
| 218 | */ | ||
| 219 | GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); | ||
| 220 | |||
| 221 | #endif /* GHOSTTY_VT_KEY_ENCODER_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h created+474| ... | @@ -0,0 +1,474 @@ | ||
| 1 | /** | ||
| 2 | * @file event.h | ||
| 3 | * | ||
| 4 | * Key event representation and manipulation. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_KEY_EVENT_H | ||
| 8 | #define GHOSTTY_VT_KEY_EVENT_H | ||
| 9 | |||
| 10 | #include <stdbool.h> | ||
| 11 | #include <stddef.h> | ||
| 12 | #include <stdint.h> | ||
| 13 | #include <ghostty/vt/result.h> | ||
| 14 | #include <ghostty/vt/allocator.h> | ||
| 15 | |||
| 16 | /** | ||
| 17 | * Opaque handle to a key event. | ||
| 18 | * | ||
| 19 | * This handle represents a keyboard input event containing information about | ||
| 20 | * the physical key pressed, modifiers, and generated text. | ||
| 21 | * | ||
| 22 | * @ingroup key | ||
| 23 | */ | ||
| 24 | typedef struct GhosttyKeyEvent *GhosttyKeyEvent; | ||
| 25 | |||
| 26 | /** | ||
| 27 | * Keyboard input event types. | ||
| 28 | * | ||
| 29 | * @ingroup key | ||
| 30 | */ | ||
| 31 | typedef enum { | ||
| 32 | /** Key was released */ | ||
| 33 | GHOSTTY_KEY_ACTION_RELEASE = 0, | ||
| 34 | /** Key was pressed */ | ||
| 35 | GHOSTTY_KEY_ACTION_PRESS = 1, | ||
| 36 | /** Key is being repeated (held down) */ | ||
| 37 | GHOSTTY_KEY_ACTION_REPEAT = 2, | ||
| 38 | } GhosttyKeyAction; | ||
| 39 | |||
| 40 | /** | ||
| 41 | * Keyboard modifier keys bitmask. | ||
| 42 | * | ||
| 43 | * A bitmask representing all keyboard modifiers. This tracks which modifier keys | ||
| 44 | * are pressed and, where supported by the platform, which side (left or right) | ||
| 45 | * of each modifier is active. | ||
| 46 | * | ||
| 47 | * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. | ||
| 48 | * | ||
| 49 | * Modifier side bits are only meaningful when the corresponding modifier bit is set. | ||
| 50 | * Not all platforms support distinguishing between left and right modifier | ||
| 51 | * keys and Ghostty is built to expect that some platforms may not provide this | ||
| 52 | * information. | ||
| 53 | * | ||
| 54 | * @ingroup key | ||
| 55 | */ | ||
| 56 | typedef uint16_t GhosttyMods; | ||
| 57 | |||
| 58 | /** Shift key is pressed */ | ||
| 59 | #define GHOSTTY_MODS_SHIFT (1 << 0) | ||
| 60 | /** Control key is pressed */ | ||
| 61 | #define GHOSTTY_MODS_CTRL (1 << 1) | ||
| 62 | /** Alt/Option key is pressed */ | ||
| 63 | #define GHOSTTY_MODS_ALT (1 << 2) | ||
| 64 | /** Super/Command/Windows key is pressed */ | ||
| 65 | #define GHOSTTY_MODS_SUPER (1 << 3) | ||
| 66 | /** Caps Lock is active */ | ||
| 67 | #define GHOSTTY_MODS_CAPS_LOCK (1 << 4) | ||
| 68 | /** Num Lock is active */ | ||
| 69 | #define GHOSTTY_MODS_NUM_LOCK (1 << 5) | ||
| 70 | |||
| 71 | /** | ||
| 72 | * Right shift is pressed (0 = left, 1 = right). | ||
| 73 | * Only meaningful when GHOSTTY_MODS_SHIFT is set. | ||
| 74 | */ | ||
| 75 | #define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) | ||
| 76 | /** | ||
| 77 | * Right ctrl is pressed (0 = left, 1 = right). | ||
| 78 | * Only meaningful when GHOSTTY_MODS_CTRL is set. | ||
| 79 | */ | ||
| 80 | #define GHOSTTY_MODS_CTRL_SIDE (1 << 7) | ||
| 81 | /** | ||
| 82 | * Right alt is pressed (0 = left, 1 = right). | ||
| 83 | * Only meaningful when GHOSTTY_MODS_ALT is set. | ||
| 84 | */ | ||
| 85 | #define GHOSTTY_MODS_ALT_SIDE (1 << 8) | ||
| 86 | /** | ||
| 87 | * Right super is pressed (0 = left, 1 = right). | ||
| 88 | * Only meaningful when GHOSTTY_MODS_SUPER is set. | ||
| 89 | */ | ||
| 90 | #define GHOSTTY_MODS_SUPER_SIDE (1 << 9) | ||
| 91 | |||
| 92 | /** | ||
| 93 | * Physical key codes. | ||
| 94 | * | ||
| 95 | * The set of key codes that Ghostty is aware of. These represent physical keys | ||
| 96 | * on the keyboard and are layout-independent. For example, the "a" key on a US | ||
| 97 | * keyboard is the same as the "ф" key on a Russian keyboard, but both will | ||
| 98 | * report the same key_a value. | ||
| 99 | * | ||
| 100 | * Layout-dependent strings are provided separately as UTF-8 text and are produced | ||
| 101 | * by the platform. These values are based on the W3C UI Events KeyboardEvent code | ||
| 102 | * standard. See: https://www.w3.org/TR/uievents-code | ||
| 103 | * | ||
| 104 | * @ingroup key | ||
| 105 | */ | ||
| 106 | typedef enum { | ||
| 107 | GHOSTTY_KEY_UNIDENTIFIED = 0, | ||
| 108 | |||
| 109 | // Writing System Keys (W3C § 3.1.1) | ||
| 110 | GHOSTTY_KEY_BACKQUOTE, | ||
| 111 | GHOSTTY_KEY_BACKSLASH, | ||
| 112 | GHOSTTY_KEY_BRACKET_LEFT, | ||
| 113 | GHOSTTY_KEY_BRACKET_RIGHT, | ||
| 114 | GHOSTTY_KEY_COMMA, | ||
| 115 | GHOSTTY_KEY_DIGIT_0, | ||
| 116 | GHOSTTY_KEY_DIGIT_1, | ||
| 117 | GHOSTTY_KEY_DIGIT_2, | ||
| 118 | GHOSTTY_KEY_DIGIT_3, | ||
| 119 | GHOSTTY_KEY_DIGIT_4, | ||
| 120 | GHOSTTY_KEY_DIGIT_5, | ||
| 121 | GHOSTTY_KEY_DIGIT_6, | ||
| 122 | GHOSTTY_KEY_DIGIT_7, | ||
| 123 | GHOSTTY_KEY_DIGIT_8, | ||
| 124 | GHOSTTY_KEY_DIGIT_9, | ||
| 125 | GHOSTTY_KEY_EQUAL, | ||
| 126 | GHOSTTY_KEY_INTL_BACKSLASH, | ||
| 127 | GHOSTTY_KEY_INTL_RO, | ||
| 128 | GHOSTTY_KEY_INTL_YEN, | ||
| 129 | GHOSTTY_KEY_A, | ||
| 130 | GHOSTTY_KEY_B, | ||
| 131 | GHOSTTY_KEY_C, | ||
| 132 | GHOSTTY_KEY_D, | ||
| 133 | GHOSTTY_KEY_E, | ||
| 134 | GHOSTTY_KEY_F, | ||
| 135 | GHOSTTY_KEY_G, | ||
| 136 | GHOSTTY_KEY_H, | ||
| 137 | GHOSTTY_KEY_I, | ||
| 138 | GHOSTTY_KEY_J, | ||
| 139 | GHOSTTY_KEY_K, | ||
| 140 | GHOSTTY_KEY_L, | ||
| 141 | GHOSTTY_KEY_M, | ||
| 142 | GHOSTTY_KEY_N, | ||
| 143 | GHOSTTY_KEY_O, | ||
| 144 | GHOSTTY_KEY_P, | ||
| 145 | GHOSTTY_KEY_Q, | ||
| 146 | GHOSTTY_KEY_R, | ||
| 147 | GHOSTTY_KEY_S, | ||
| 148 | GHOSTTY_KEY_T, | ||
| 149 | GHOSTTY_KEY_U, | ||
| 150 | GHOSTTY_KEY_V, | ||
| 151 | GHOSTTY_KEY_W, | ||
| 152 | GHOSTTY_KEY_X, | ||
| 153 | GHOSTTY_KEY_Y, | ||
| 154 | GHOSTTY_KEY_Z, | ||
| 155 | GHOSTTY_KEY_MINUS, | ||
| 156 | GHOSTTY_KEY_PERIOD, | ||
| 157 | GHOSTTY_KEY_QUOTE, | ||
| 158 | GHOSTTY_KEY_SEMICOLON, | ||
| 159 | GHOSTTY_KEY_SLASH, | ||
| 160 | |||
| 161 | // Functional Keys (W3C § 3.1.2) | ||
| 162 | GHOSTTY_KEY_ALT_LEFT, | ||
| 163 | GHOSTTY_KEY_ALT_RIGHT, | ||
| 164 | GHOSTTY_KEY_BACKSPACE, | ||
| 165 | GHOSTTY_KEY_CAPS_LOCK, | ||
| 166 | GHOSTTY_KEY_CONTEXT_MENU, | ||
| 167 | GHOSTTY_KEY_CONTROL_LEFT, | ||
| 168 | GHOSTTY_KEY_CONTROL_RIGHT, | ||
| 169 | GHOSTTY_KEY_ENTER, | ||
| 170 | GHOSTTY_KEY_META_LEFT, | ||
| 171 | GHOSTTY_KEY_META_RIGHT, | ||
| 172 | GHOSTTY_KEY_SHIFT_LEFT, | ||
| 173 | GHOSTTY_KEY_SHIFT_RIGHT, | ||
| 174 | GHOSTTY_KEY_SPACE, | ||
| 175 | GHOSTTY_KEY_TAB, | ||
| 176 | GHOSTTY_KEY_CONVERT, | ||
| 177 | GHOSTTY_KEY_KANA_MODE, | ||
| 178 | GHOSTTY_KEY_NON_CONVERT, | ||
| 179 | |||
| 180 | // Control Pad Section (W3C § 3.2) | ||
| 181 | GHOSTTY_KEY_DELETE, | ||
| 182 | GHOSTTY_KEY_END, | ||
| 183 | GHOSTTY_KEY_HELP, | ||
| 184 | GHOSTTY_KEY_HOME, | ||
| 185 | GHOSTTY_KEY_INSERT, | ||
| 186 | GHOSTTY_KEY_PAGE_DOWN, | ||
| 187 | GHOSTTY_KEY_PAGE_UP, | ||
| 188 | |||
| 189 | // Arrow Pad Section (W3C § 3.3) | ||
| 190 | GHOSTTY_KEY_ARROW_DOWN, | ||
| 191 | GHOSTTY_KEY_ARROW_LEFT, | ||
| 192 | GHOSTTY_KEY_ARROW_RIGHT, | ||
| 193 | GHOSTTY_KEY_ARROW_UP, | ||
| 194 | |||
| 195 | // Numpad Section (W3C § 3.4) | ||
| 196 | GHOSTTY_KEY_NUM_LOCK, | ||
| 197 | GHOSTTY_KEY_NUMPAD_0, | ||
| 198 | GHOSTTY_KEY_NUMPAD_1, | ||
| 199 | GHOSTTY_KEY_NUMPAD_2, | ||
| 200 | GHOSTTY_KEY_NUMPAD_3, | ||
| 201 | GHOSTTY_KEY_NUMPAD_4, | ||
| 202 | GHOSTTY_KEY_NUMPAD_5, | ||
| 203 | GHOSTTY_KEY_NUMPAD_6, | ||
| 204 | GHOSTTY_KEY_NUMPAD_7, | ||
| 205 | GHOSTTY_KEY_NUMPAD_8, | ||
| 206 | GHOSTTY_KEY_NUMPAD_9, | ||
| 207 | GHOSTTY_KEY_NUMPAD_ADD, | ||
| 208 | GHOSTTY_KEY_NUMPAD_BACKSPACE, | ||
| 209 | GHOSTTY_KEY_NUMPAD_CLEAR, | ||
| 210 | GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, | ||
| 211 | GHOSTTY_KEY_NUMPAD_COMMA, | ||
| 212 | GHOSTTY_KEY_NUMPAD_DECIMAL, | ||
| 213 | GHOSTTY_KEY_NUMPAD_DIVIDE, | ||
| 214 | GHOSTTY_KEY_NUMPAD_ENTER, | ||
| 215 | GHOSTTY_KEY_NUMPAD_EQUAL, | ||
| 216 | GHOSTTY_KEY_NUMPAD_MEMORY_ADD, | ||
| 217 | GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, | ||
| 218 | GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, | ||
| 219 | GHOSTTY_KEY_NUMPAD_MEMORY_STORE, | ||
| 220 | GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, | ||
| 221 | GHOSTTY_KEY_NUMPAD_MULTIPLY, | ||
| 222 | GHOSTTY_KEY_NUMPAD_PAREN_LEFT, | ||
| 223 | GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, | ||
| 224 | GHOSTTY_KEY_NUMPAD_SUBTRACT, | ||
| 225 | GHOSTTY_KEY_NUMPAD_SEPARATOR, | ||
| 226 | GHOSTTY_KEY_NUMPAD_UP, | ||
| 227 | GHOSTTY_KEY_NUMPAD_DOWN, | ||
| 228 | GHOSTTY_KEY_NUMPAD_RIGHT, | ||
| 229 | GHOSTTY_KEY_NUMPAD_LEFT, | ||
| 230 | GHOSTTY_KEY_NUMPAD_BEGIN, | ||
| 231 | GHOSTTY_KEY_NUMPAD_HOME, | ||
| 232 | GHOSTTY_KEY_NUMPAD_END, | ||
| 233 | GHOSTTY_KEY_NUMPAD_INSERT, | ||
| 234 | GHOSTTY_KEY_NUMPAD_DELETE, | ||
| 235 | GHOSTTY_KEY_NUMPAD_PAGE_UP, | ||
| 236 | GHOSTTY_KEY_NUMPAD_PAGE_DOWN, | ||
| 237 | |||
| 238 | // Function Section (W3C § 3.5) | ||
| 239 | GHOSTTY_KEY_ESCAPE, | ||
| 240 | GHOSTTY_KEY_F1, | ||
| 241 | GHOSTTY_KEY_F2, | ||
| 242 | GHOSTTY_KEY_F3, | ||
| 243 | GHOSTTY_KEY_F4, | ||
| 244 | GHOSTTY_KEY_F5, | ||
| 245 | GHOSTTY_KEY_F6, | ||
| 246 | GHOSTTY_KEY_F7, | ||
| 247 | GHOSTTY_KEY_F8, | ||
| 248 | GHOSTTY_KEY_F9, | ||
| 249 | GHOSTTY_KEY_F10, | ||
| 250 | GHOSTTY_KEY_F11, | ||
| 251 | GHOSTTY_KEY_F12, | ||
| 252 | GHOSTTY_KEY_F13, | ||
| 253 | GHOSTTY_KEY_F14, | ||
| 254 | GHOSTTY_KEY_F15, | ||
| 255 | GHOSTTY_KEY_F16, | ||
| 256 | GHOSTTY_KEY_F17, | ||
| 257 | GHOSTTY_KEY_F18, | ||
| 258 | GHOSTTY_KEY_F19, | ||
| 259 | GHOSTTY_KEY_F20, | ||
| 260 | GHOSTTY_KEY_F21, | ||
| 261 | GHOSTTY_KEY_F22, | ||
| 262 | GHOSTTY_KEY_F23, | ||
| 263 | GHOSTTY_KEY_F24, | ||
| 264 | GHOSTTY_KEY_F25, | ||
| 265 | GHOSTTY_KEY_FN, | ||
| 266 | GHOSTTY_KEY_FN_LOCK, | ||
| 267 | GHOSTTY_KEY_PRINT_SCREEN, | ||
| 268 | GHOSTTY_KEY_SCROLL_LOCK, | ||
| 269 | GHOSTTY_KEY_PAUSE, | ||
| 270 | |||
| 271 | // Media Keys (W3C § 3.6) | ||
| 272 | GHOSTTY_KEY_BROWSER_BACK, | ||
| 273 | GHOSTTY_KEY_BROWSER_FAVORITES, | ||
| 274 | GHOSTTY_KEY_BROWSER_FORWARD, | ||
| 275 | GHOSTTY_KEY_BROWSER_HOME, | ||
| 276 | GHOSTTY_KEY_BROWSER_REFRESH, | ||
| 277 | GHOSTTY_KEY_BROWSER_SEARCH, | ||
| 278 | GHOSTTY_KEY_BROWSER_STOP, | ||
| 279 | GHOSTTY_KEY_EJECT, | ||
| 280 | GHOSTTY_KEY_LAUNCH_APP_1, | ||
| 281 | GHOSTTY_KEY_LAUNCH_APP_2, | ||
| 282 | GHOSTTY_KEY_LAUNCH_MAIL, | ||
| 283 | GHOSTTY_KEY_MEDIA_PLAY_PAUSE, | ||
| 284 | GHOSTTY_KEY_MEDIA_SELECT, | ||
| 285 | GHOSTTY_KEY_MEDIA_STOP, | ||
| 286 | GHOSTTY_KEY_MEDIA_TRACK_NEXT, | ||
| 287 | GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, | ||
| 288 | GHOSTTY_KEY_POWER, | ||
| 289 | GHOSTTY_KEY_SLEEP, | ||
| 290 | GHOSTTY_KEY_AUDIO_VOLUME_DOWN, | ||
| 291 | GHOSTTY_KEY_AUDIO_VOLUME_MUTE, | ||
| 292 | GHOSTTY_KEY_AUDIO_VOLUME_UP, | ||
| 293 | GHOSTTY_KEY_WAKE_UP, | ||
| 294 | |||
| 295 | // Legacy, Non-standard, and Special Keys (W3C § 3.7) | ||
| 296 | GHOSTTY_KEY_COPY, | ||
| 297 | GHOSTTY_KEY_CUT, | ||
| 298 | GHOSTTY_KEY_PASTE, | ||
| 299 | } GhosttyKey; | ||
| 300 | |||
| 301 | /** | ||
| 302 | * Create a new key event instance. | ||
| 303 | * | ||
| 304 | * Creates a new key event with default values. The event must be freed using | ||
| 305 | * ghostty_key_event_free() when no longer needed. | ||
| 306 | * | ||
| 307 | * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator | ||
| 308 | * @param event Pointer to store the created key event handle | ||
| 309 | * @return GHOSTTY_SUCCESS on success, or an error code on failure | ||
| 310 | * | ||
| 311 | * @ingroup key | ||
| 312 | */ | ||
| 313 | GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); | ||
| 314 | |||
| 315 | /** | ||
| 316 | * Free a key event instance. | ||
| 317 | * | ||
| 318 | * Releases all resources associated with the key event. After this call, | ||
| 319 | * the event handle becomes invalid and must not be used. | ||
| 320 | * | ||
| 321 | * @param event The key event handle to free (may be NULL) | ||
| 322 | * | ||
| 323 | * @ingroup key | ||
| 324 | */ | ||
| 325 | void ghostty_key_event_free(GhosttyKeyEvent event); | ||
| 326 | |||
| 327 | /** | ||
| 328 | * Set the key action (press, release, repeat). | ||
| 329 | * | ||
| 330 | * @param event The key event handle, must not be NULL | ||
| 331 | * @param action The action to set | ||
| 332 | * | ||
| 333 | * @ingroup key | ||
| 334 | */ | ||
| 335 | void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); | ||
| 336 | |||
| 337 | /** | ||
| 338 | * Get the key action (press, release, repeat). | ||
| 339 | * | ||
| 340 | * @param event The key event handle, must not be NULL | ||
| 341 | * @return The key action | ||
| 342 | * | ||
| 343 | * @ingroup key | ||
| 344 | */ | ||
| 345 | GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); | ||
| 346 | |||
| 347 | /** | ||
| 348 | * Set the physical key code. | ||
| 349 | * | ||
| 350 | * @param event The key event handle, must not be NULL | ||
| 351 | * @param key The physical key code to set | ||
| 352 | * | ||
| 353 | * @ingroup key | ||
| 354 | */ | ||
| 355 | void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); | ||
| 356 | |||
| 357 | /** | ||
| 358 | * Get the physical key code. | ||
| 359 | * | ||
| 360 | * @param event The key event handle, must not be NULL | ||
| 361 | * @return The physical key code | ||
| 362 | * | ||
| 363 | * @ingroup key | ||
| 364 | */ | ||
| 365 | GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); | ||
| 366 | |||
| 367 | /** | ||
| 368 | * Set the modifier keys bitmask. | ||
| 369 | * | ||
| 370 | * @param event The key event handle, must not be NULL | ||
| 371 | * @param mods The modifier keys bitmask to set | ||
| 372 | * | ||
| 373 | * @ingroup key | ||
| 374 | */ | ||
| 375 | void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); | ||
| 376 | |||
| 377 | /** | ||
| 378 | * Get the modifier keys bitmask. | ||
| 379 | * | ||
| 380 | * @param event The key event handle, must not be NULL | ||
| 381 | * @return The modifier keys bitmask | ||
| 382 | * | ||
| 383 | * @ingroup key | ||
| 384 | */ | ||
| 385 | GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); | ||
| 386 | |||
| 387 | /** | ||
| 388 | * Set the consumed modifiers bitmask. | ||
| 389 | * | ||
| 390 | * @param event The key event handle, must not be NULL | ||
| 391 | * @param consumed_mods The consumed modifiers bitmask to set | ||
| 392 | * | ||
| 393 | * @ingroup key | ||
| 394 | */ | ||
| 395 | void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); | ||
| 396 | |||
| 397 | /** | ||
| 398 | * Get the consumed modifiers bitmask. | ||
| 399 | * | ||
| 400 | * @param event The key event handle, must not be NULL | ||
| 401 | * @return The consumed modifiers bitmask | ||
| 402 | * | ||
| 403 | * @ingroup key | ||
| 404 | */ | ||
| 405 | GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); | ||
| 406 | |||
| 407 | /** | ||
| 408 | * Set whether the key event is part of a composition sequence. | ||
| 409 | * | ||
| 410 | * @param event The key event handle, must not be NULL | ||
| 411 | * @param composing Whether the key event is part of a composition sequence | ||
| 412 | * | ||
| 413 | * @ingroup key | ||
| 414 | */ | ||
| 415 | void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); | ||
| 416 | |||
| 417 | /** | ||
| 418 | * Get whether the key event is part of a composition sequence. | ||
| 419 | * | ||
| 420 | * @param event The key event handle, must not be NULL | ||
| 421 | * @return Whether the key event is part of a composition sequence | ||
| 422 | * | ||
| 423 | * @ingroup key | ||
| 424 | */ | ||
| 425 | bool ghostty_key_event_get_composing(GhosttyKeyEvent event); | ||
| 426 | |||
| 427 | /** | ||
| 428 | * Set the UTF-8 text generated by the key event. | ||
| 429 | * | ||
| 430 | * The key event does NOT take ownership of the text pointer. The caller | ||
| 431 | * must ensure the string remains valid for the lifetime needed by the event. | ||
| 432 | * | ||
| 433 | * @param event The key event handle, must not be NULL | ||
| 434 | * @param utf8 The UTF-8 text to set (or NULL for empty) | ||
| 435 | * @param len Length of the UTF-8 text in bytes | ||
| 436 | * | ||
| 437 | * @ingroup key | ||
| 438 | */ | ||
| 439 | void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); | ||
| 440 | |||
| 441 | /** | ||
| 442 | * Get the UTF-8 text generated by the key event. | ||
| 443 | * | ||
| 444 | * The returned pointer is valid until the event is freed or the UTF-8 text is modified. | ||
| 445 | * | ||
| 446 | * @param event The key event handle, must not be NULL | ||
| 447 | * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) | ||
| 448 | * @return The UTF-8 text (or NULL for empty) | ||
| 449 | * | ||
| 450 | * @ingroup key | ||
| 451 | */ | ||
| 452 | const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); | ||
| 453 | |||
| 454 | /** | ||
| 455 | * Set the unshifted Unicode codepoint. | ||
| 456 | * | ||
| 457 | * @param event The key event handle, must not be NULL | ||
| 458 | * @param codepoint The unshifted Unicode codepoint to set | ||
| 459 | * | ||
| 460 | * @ingroup key | ||
| 461 | */ | ||
| 462 | void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); | ||
| 463 | |||
| 464 | /** | ||
| 465 | * Get the unshifted Unicode codepoint. | ||
| 466 | * | ||
| 467 | * @param event The key event handle, must not be NULL | ||
| 468 | * @return The unshifted Unicode codepoint | ||
| 469 | * | ||
| 470 | * @ingroup key | ||
| 471 | */ | ||
| 472 | uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); | ||
| 473 | |||
| 474 | #endif /* GHOSTTY_VT_KEY_EVENT_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h created+233| ... | @@ -0,0 +1,233 @@ | ||
| 1 | /** | ||
| 2 | * @file osc.h | ||
| 3 | * | ||
| 4 | * OSC (Operating System Command) sequence parser and command handling. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_OSC_H | ||
| 8 | #define GHOSTTY_VT_OSC_H | ||
| 9 | |||
| 10 | #include <stdbool.h> | ||
| 11 | #include <stddef.h> | ||
| 12 | #include <stdint.h> | ||
| 13 | #include <ghostty/vt/result.h> | ||
| 14 | #include <ghostty/vt/allocator.h> | ||
| 15 | |||
| 16 | /** | ||
| 17 | * Opaque handle to an OSC parser instance. | ||
| 18 | * | ||
| 19 | * This handle represents an OSC (Operating System Command) parser that can | ||
| 20 | * be used to parse the contents of OSC sequences. | ||
| 21 | * | ||
| 22 | * @ingroup osc | ||
| 23 | */ | ||
| 24 | typedef struct GhosttyOscParser *GhosttyOscParser; | ||
| 25 | |||
| 26 | /** | ||
| 27 | * Opaque handle to a single OSC command. | ||
| 28 | * | ||
| 29 | * This handle represents a parsed OSC (Operating System Command) command. | ||
| 30 | * The command can be queried for its type and associated data. | ||
| 31 | * | ||
| 32 | * @ingroup osc | ||
| 33 | */ | ||
| 34 | typedef struct GhosttyOscCommand *GhosttyOscCommand; | ||
| 35 | |||
| 36 | /** @defgroup osc OSC Parser | ||
| 37 | * | ||
| 38 | * OSC (Operating System Command) sequence parser and command handling. | ||
| 39 | * | ||
| 40 | * The parser operates in a streaming fashion, processing input byte-by-byte | ||
| 41 | * to handle OSC sequences that may arrive in fragments across multiple reads. | ||
| 42 | * This interface makes it easy to integrate into most environments and avoids | ||
| 43 | * over-allocating buffers. | ||
| 44 | * | ||
| 45 | * ## Basic Usage | ||
| 46 | * | ||
| 47 | * 1. Create a parser instance with ghostty_osc_new() | ||
| 48 | * 2. Feed bytes to the parser using ghostty_osc_next() | ||
| 49 | * 3. Finalize parsing with ghostty_osc_end() to get the command | ||
| 50 | * 4. Query command type and extract data using ghostty_osc_command_type() | ||
| 51 | * and ghostty_osc_command_data() | ||
| 52 | * 5. Free the parser with ghostty_osc_free() when done | ||
| 53 | * | ||
| 54 | * @{ | ||
| 55 | */ | ||
| 56 | |||
| 57 | /** | ||
| 58 | * OSC command types. | ||
| 59 | * | ||
| 60 | * @ingroup osc | ||
| 61 | */ | ||
| 62 | typedef enum { | ||
| 63 | GHOSTTY_OSC_COMMAND_INVALID = 0, | ||
| 64 | GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, | ||
| 65 | GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, | ||
| 66 | GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3, | ||
| 67 | GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4, | ||
| 68 | GHOSTTY_OSC_COMMAND_REPORT_PWD = 5, | ||
| 69 | GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6, | ||
| 70 | GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7, | ||
| 71 | GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8, | ||
| 72 | GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9, | ||
| 73 | GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10, | ||
| 74 | GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11, | ||
| 75 | GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12, | ||
| 76 | GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13, | ||
| 77 | GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14, | ||
| 78 | GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15, | ||
| 79 | GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16, | ||
| 80 | GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17, | ||
| 81 | GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18, | ||
| 82 | GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19, | ||
| 83 | GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, | ||
| 84 | GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, | ||
| 85 | GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, | ||
| 86 | } GhosttyOscCommandType; | ||
| 87 | |||
| 88 | /** | ||
| 89 | * OSC command data types. | ||
| 90 | * | ||
| 91 | * These values specify what type of data to extract from an OSC command | ||
| 92 | * using `ghostty_osc_command_data`. | ||
| 93 | * | ||
| 94 | * @ingroup osc | ||
| 95 | */ | ||
| 96 | typedef enum { | ||
| 97 | /** Invalid data type. Never results in any data extraction. */ | ||
| 98 | GHOSTTY_OSC_DATA_INVALID = 0, | ||
| 99 | |||
| 100 | /** | ||
| 101 | * Window title string data. | ||
| 102 | * | ||
| 103 | * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE | ||
| 104 | * | ||
| 105 | * Output type: const char ** (pointer to null-terminated string) | ||
| 106 | * | ||
| 107 | * Lifetime: Valid until the next call to any ghostty_osc_* function with | ||
| 108 | * the same parser instance. Memory is owned by the parser. | ||
| 109 | */ | ||
| 110 | GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, | ||
| 111 | } GhosttyOscCommandData; | ||
| 112 | |||
| 113 | /** | ||
| 114 | * Create a new OSC parser instance. | ||
| 115 | * | ||
| 116 | * Creates a new OSC (Operating System Command) parser using the provided | ||
| 117 | * allocator. The parser must be freed using ghostty_vt_osc_free() when | ||
| 118 | * no longer needed. | ||
| 119 | * | ||
| 120 | * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator | ||
| 121 | * @param parser Pointer to store the created parser handle | ||
| 122 | * @return GHOSTTY_SUCCESS on success, or an error code on failure | ||
| 123 | * | ||
| 124 | * @ingroup osc | ||
| 125 | */ | ||
| 126 | GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); | ||
| 127 | |||
| 128 | /** | ||
| 129 | * Free an OSC parser instance. | ||
| 130 | * | ||
| 131 | * Releases all resources associated with the OSC parser. After this call, | ||
| 132 | * the parser handle becomes invalid and must not be used. | ||
| 133 | * | ||
| 134 | * @param parser The parser handle to free (may be NULL) | ||
| 135 | * | ||
| 136 | * @ingroup osc | ||
| 137 | */ | ||
| 138 | void ghostty_osc_free(GhosttyOscParser parser); | ||
| 139 | |||
| 140 | /** | ||
| 141 | * Reset an OSC parser instance to its initial state. | ||
| 142 | * | ||
| 143 | * Resets the parser state, clearing any partially parsed OSC sequences | ||
| 144 | * and returning the parser to its initial state. This is useful for | ||
| 145 | * reusing a parser instance or recovering from parse errors. | ||
| 146 | * | ||
| 147 | * @param parser The parser handle to reset, must not be null. | ||
| 148 | * | ||
| 149 | * @ingroup osc | ||
| 150 | */ | ||
| 151 | void ghostty_osc_reset(GhosttyOscParser parser); | ||
| 152 | |||
| 153 | /** | ||
| 154 | * Parse the next byte in an OSC sequence. | ||
| 155 | * | ||
| 156 | * Processes a single byte as part of an OSC sequence. The parser maintains | ||
| 157 | * internal state to track the progress through the sequence. Call this | ||
| 158 | * function for each byte in the sequence data. | ||
| 159 | * | ||
| 160 | * When finished pumping the parser with bytes, call ghostty_osc_end | ||
| 161 | * to get the final result. | ||
| 162 | * | ||
| 163 | * @param parser The parser handle, must not be null. | ||
| 164 | * @param byte The next byte to parse | ||
| 165 | * | ||
| 166 | * @ingroup osc | ||
| 167 | */ | ||
| 168 | void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); | ||
| 169 | |||
| 170 | /** | ||
| 171 | * Finalize OSC parsing and retrieve the parsed command. | ||
| 172 | * | ||
| 173 | * Call this function after feeding all bytes of an OSC sequence to the parser | ||
| 174 | * using ghostty_osc_next() with the exception of the terminating character | ||
| 175 | * (ESC or ST). This function finalizes the parsing process and returns the | ||
| 176 | * parsed OSC command. | ||
| 177 | * | ||
| 178 | * The return value is never NULL. Invalid commands will return a command | ||
| 179 | * with type GHOSTTY_OSC_COMMAND_INVALID. | ||
| 180 | * | ||
| 181 | * The terminator parameter specifies the byte that terminated the OSC sequence | ||
| 182 | * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is | ||
| 183 | * preserved in the parsed command so that responses can use the same terminator | ||
| 184 | * format for better compatibility with the calling program. For commands that | ||
| 185 | * do not require a response, this parameter is ignored and the resulting | ||
| 186 | * command will not retain the terminator information. | ||
| 187 | * | ||
| 188 | * The returned command handle is valid until the next call to any | ||
| 189 | * `ghostty_osc_*` function with the same parser instance with the exception | ||
| 190 | * of command introspection functions such as `ghostty_osc_command_type`. | ||
| 191 | * | ||
| 192 | * @param parser The parser handle, must not be null. | ||
| 193 | * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) | ||
| 194 | * @return Handle to the parsed OSC command | ||
| 195 | * | ||
| 196 | * @ingroup osc | ||
| 197 | */ | ||
| 198 | GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); | ||
| 199 | |||
| 200 | /** | ||
| 201 | * Get the type of an OSC command. | ||
| 202 | * | ||
| 203 | * Returns the type identifier for the given OSC command. This can be used | ||
| 204 | * to determine what kind of command was parsed and what data might be | ||
| 205 | * available from it. | ||
| 206 | * | ||
| 207 | * @param command The OSC command handle to query (may be NULL) | ||
| 208 | * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL | ||
| 209 | * | ||
| 210 | * @ingroup osc | ||
| 211 | */ | ||
| 212 | GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); | ||
| 213 | |||
| 214 | /** | ||
| 215 | * Extract data from an OSC command. | ||
| 216 | * | ||
| 217 | * Extracts typed data from the given OSC command based on the specified | ||
| 218 | * data type. The output pointer must be of the appropriate type for the | ||
| 219 | * requested data kind. Valid command types, output types, and memory | ||
| 220 | * safety information are documented in the `GhosttyOscCommandData` enum. | ||
| 221 | * | ||
| 222 | * @param command The OSC command handle to query (may be NULL) | ||
| 223 | * @param data The type of data to extract | ||
| 224 | * @param out Pointer to store the extracted data (type depends on data parameter) | ||
| 225 | * @return true if data extraction was successful, false otherwise | ||
| 226 | * | ||
| 227 | * @ingroup osc | ||
| 228 | */ | ||
| 229 | bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); | ||
| 230 | |||
| 231 | /** @} */ | ||
| 232 | |||
| 233 | #endif /* GHOSTTY_VT_OSC_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h created+75| ... | @@ -0,0 +1,75 @@ | ||
| 1 | /** | ||
| 2 | * @file paste.h | ||
| 3 | * | ||
| 4 | * Paste utilities - validate and encode paste data for terminal input. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_PASTE_H | ||
| 8 | #define GHOSTTY_VT_PASTE_H | ||
| 9 | |||
| 10 | /** @defgroup paste Paste Utilities | ||
| 11 | * | ||
| 12 | * Utilities for validating paste data safety. | ||
| 13 | * | ||
| 14 | * ## Basic Usage | ||
| 15 | * | ||
| 16 | * Use ghostty_paste_is_safe() to check if paste data contains potentially | ||
| 17 | * dangerous sequences before sending it to the terminal. | ||
| 18 | * | ||
| 19 | * ## Example | ||
| 20 | * | ||
| 21 | * @code{.c} | ||
| 22 | * #include <stdio.h> | ||
| 23 | * #include <string.h> | ||
| 24 | * #include <ghostty/vt.h> | ||
| 25 | * | ||
| 26 | * int main() { | ||
| 27 | * const char* safe_data = "hello world"; | ||
| 28 | * const char* unsafe_data = "rm -rf /\n"; | ||
| 29 | * | ||
| 30 | * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { | ||
| 31 | * printf("Safe to paste\n"); | ||
| 32 | * } | ||
| 33 | * | ||
| 34 | * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { | ||
| 35 | * printf("Unsafe! Contains newline\n"); | ||
| 36 | * } | ||
| 37 | * | ||
| 38 | * return 0; | ||
| 39 | * } | ||
| 40 | * @endcode | ||
| 41 | * | ||
| 42 | * @{ | ||
| 43 | */ | ||
| 44 | |||
| 45 | #include <stdbool.h> | ||
| 46 | #include <stddef.h> | ||
| 47 | |||
| 48 | #ifdef __cplusplus | ||
| 49 | extern "C" { | ||
| 50 | #endif | ||
| 51 | |||
| 52 | /** | ||
| 53 | * Check if paste data is safe to paste into the terminal. | ||
| 54 | * | ||
| 55 | * Data is considered unsafe if it contains: | ||
| 56 | * - Newlines (`\n`) which can inject commands | ||
| 57 | * - The bracketed paste end sequence (`\x1b[201~`) which can be used | ||
| 58 | * to exit bracketed paste mode and inject commands | ||
| 59 | * | ||
| 60 | * This check is conservative and considers data unsafe regardless of | ||
| 61 | * current terminal state. | ||
| 62 | * | ||
| 63 | * @param data The paste data to check (must not be NULL) | ||
| 64 | * @param len The length of the data in bytes | ||
| 65 | * @return true if the data is safe to paste, false otherwise | ||
| 66 | */ | ||
| 67 | bool ghostty_paste_is_safe(const char* data, size_t len); | ||
| 68 | |||
| 69 | #ifdef __cplusplus | ||
| 70 | } | ||
| 71 | #endif | ||
| 72 | |||
| 73 | /** @} */ | ||
| 74 | |||
| 75 | #endif /* GHOSTTY_VT_PASTE_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | /** | ||
| 2 | * @file result.h | ||
| 3 | * | ||
| 4 | * Result codes for libghostty-vt operations. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_RESULT_H | ||
| 8 | #define GHOSTTY_VT_RESULT_H | ||
| 9 | |||
| 10 | /** | ||
| 11 | * Result codes for libghostty-vt operations. | ||
| 12 | */ | ||
| 13 | typedef enum { | ||
| 14 | /** Operation completed successfully */ | ||
| 15 | GHOSTTY_SUCCESS = 0, | ||
| 16 | /** Operation failed due to failed allocation */ | ||
| 17 | GHOSTTY_OUT_OF_MEMORY = -1, | ||
| 18 | /** Operation failed due to invalid value */ | ||
| 19 | GHOSTTY_INVALID_VALUE = -2, | ||
| 20 | } GhosttyResult; | ||
| 21 | |||
| 22 | #endif /* GHOSTTY_VT_RESULT_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h created+394| ... | @@ -0,0 +1,394 @@ | ||
| 1 | /** | ||
| 2 | * @file sgr.h | ||
| 3 | * | ||
| 4 | * SGR (Select Graphic Rendition) attribute parsing and handling. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_SGR_H | ||
| 8 | #define GHOSTTY_VT_SGR_H | ||
| 9 | |||
| 10 | /** @defgroup sgr SGR Parser | ||
| 11 | * | ||
| 12 | * SGR (Select Graphic Rendition) attribute parser. | ||
| 13 | * | ||
| 14 | * SGR sequences are the syntax used to set styling attributes such as | ||
| 15 | * bold, italic, underline, and colors for text in terminal emulators. | ||
| 16 | * For example, you may be familiar with sequences like `ESC[1;31m`. The | ||
| 17 | * `1;31` is the SGR attribute list. | ||
| 18 | * | ||
| 19 | * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) | ||
| 20 | * and returns individual text attributes like bold, italic, colors, etc. | ||
| 21 | * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, | ||
| 22 | * and handles various color formats including 8-color, 16-color, 256-color, | ||
| 23 | * X11 named colors, and RGB in multiple formats. | ||
| 24 | * | ||
| 25 | * ## Basic Usage | ||
| 26 | * | ||
| 27 | * 1. Create a parser instance with ghostty_sgr_new() | ||
| 28 | * 2. Set SGR parameters with ghostty_sgr_set_params() | ||
| 29 | * 3. Iterate through attributes using ghostty_sgr_next() | ||
| 30 | * 4. Free the parser with ghostty_sgr_free() when done | ||
| 31 | * | ||
| 32 | * ## Example | ||
| 33 | * | ||
| 34 | * @code{.c} | ||
| 35 | * #include <assert.h> | ||
| 36 | * #include <stdio.h> | ||
| 37 | * #include <ghostty/vt.h> | ||
| 38 | * | ||
| 39 | * int main() { | ||
| 40 | * // Create parser | ||
| 41 | * GhosttySgrParser parser; | ||
| 42 | * GhosttyResult result = ghostty_sgr_new(NULL, &parser); | ||
| 43 | * assert(result == GHOSTTY_SUCCESS); | ||
| 44 | * | ||
| 45 | * // Parse "bold, red foreground" sequence: ESC[1;31m | ||
| 46 | * uint16_t params[] = {1, 31}; | ||
| 47 | * result = ghostty_sgr_set_params(parser, params, NULL, 2); | ||
| 48 | * assert(result == GHOSTTY_SUCCESS); | ||
| 49 | * | ||
| 50 | * // Iterate through attributes | ||
| 51 | * GhosttySgrAttribute attr; | ||
| 52 | * while (ghostty_sgr_next(parser, &attr)) { | ||
| 53 | * switch (attr.tag) { | ||
| 54 | * case GHOSTTY_SGR_ATTR_BOLD: | ||
| 55 | * printf("Bold enabled\n"); | ||
| 56 | * break; | ||
| 57 | * case GHOSTTY_SGR_ATTR_FG_8: | ||
| 58 | * printf("Foreground color: %d\n", attr.value.fg_8); | ||
| 59 | * break; | ||
| 60 | * default: | ||
| 61 | * break; | ||
| 62 | * } | ||
| 63 | * } | ||
| 64 | * | ||
| 65 | * // Cleanup | ||
| 66 | * ghostty_sgr_free(parser); | ||
| 67 | * return 0; | ||
| 68 | * } | ||
| 69 | * @endcode | ||
| 70 | * | ||
| 71 | * @{ | ||
| 72 | */ | ||
| 73 | |||
| 74 | #include <ghostty/vt/allocator.h> | ||
| 75 | #include <ghostty/vt/color.h> | ||
| 76 | #include <ghostty/vt/result.h> | ||
| 77 | #include <stdbool.h> | ||
| 78 | #include <stddef.h> | ||
| 79 | #include <stdint.h> | ||
| 80 | |||
| 81 | #ifdef __cplusplus | ||
| 82 | extern "C" { | ||
| 83 | #endif | ||
| 84 | |||
| 85 | /** | ||
| 86 | * Opaque handle to an SGR parser instance. | ||
| 87 | * | ||
| 88 | * This handle represents an SGR (Select Graphic Rendition) parser that can | ||
| 89 | * be used to parse SGR sequences and extract individual text attributes. | ||
| 90 | * | ||
| 91 | * @ingroup sgr | ||
| 92 | */ | ||
| 93 | typedef struct GhosttySgrParser* GhosttySgrParser; | ||
| 94 | |||
| 95 | /** | ||
| 96 | * SGR attribute tags. | ||
| 97 | * | ||
| 98 | * These values identify the type of an SGR attribute in a tagged union. | ||
| 99 | * Use the tag to determine which field in the attribute value union to access. | ||
| 100 | * | ||
| 101 | * @ingroup sgr | ||
| 102 | */ | ||
| 103 | typedef enum { | ||
| 104 | GHOSTTY_SGR_ATTR_UNSET = 0, | ||
| 105 | GHOSTTY_SGR_ATTR_UNKNOWN = 1, | ||
| 106 | GHOSTTY_SGR_ATTR_BOLD = 2, | ||
| 107 | GHOSTTY_SGR_ATTR_RESET_BOLD = 3, | ||
| 108 | GHOSTTY_SGR_ATTR_ITALIC = 4, | ||
| 109 | GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, | ||
| 110 | GHOSTTY_SGR_ATTR_FAINT = 6, | ||
| 111 | GHOSTTY_SGR_ATTR_UNDERLINE = 7, | ||
| 112 | GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8, | ||
| 113 | GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9, | ||
| 114 | GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10, | ||
| 115 | GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11, | ||
| 116 | GHOSTTY_SGR_ATTR_OVERLINE = 12, | ||
| 117 | GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13, | ||
| 118 | GHOSTTY_SGR_ATTR_BLINK = 14, | ||
| 119 | GHOSTTY_SGR_ATTR_RESET_BLINK = 15, | ||
| 120 | GHOSTTY_SGR_ATTR_INVERSE = 16, | ||
| 121 | GHOSTTY_SGR_ATTR_RESET_INVERSE = 17, | ||
| 122 | GHOSTTY_SGR_ATTR_INVISIBLE = 18, | ||
| 123 | GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19, | ||
| 124 | GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20, | ||
| 125 | GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21, | ||
| 126 | GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22, | ||
| 127 | GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23, | ||
| 128 | GHOSTTY_SGR_ATTR_BG_8 = 24, | ||
| 129 | GHOSTTY_SGR_ATTR_FG_8 = 25, | ||
| 130 | GHOSTTY_SGR_ATTR_RESET_FG = 26, | ||
| 131 | GHOSTTY_SGR_ATTR_RESET_BG = 27, | ||
| 132 | GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28, | ||
| 133 | GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29, | ||
| 134 | GHOSTTY_SGR_ATTR_BG_256 = 30, | ||
| 135 | GHOSTTY_SGR_ATTR_FG_256 = 31, | ||
| 136 | } GhosttySgrAttributeTag; | ||
| 137 | |||
| 138 | /** | ||
| 139 | * Underline style types. | ||
| 140 | * | ||
| 141 | * @ingroup sgr | ||
| 142 | */ | ||
| 143 | typedef enum { | ||
| 144 | GHOSTTY_SGR_UNDERLINE_NONE = 0, | ||
| 145 | GHOSTTY_SGR_UNDERLINE_SINGLE = 1, | ||
| 146 | GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, | ||
| 147 | GHOSTTY_SGR_UNDERLINE_CURLY = 3, | ||
| 148 | GHOSTTY_SGR_UNDERLINE_DOTTED = 4, | ||
| 149 | GHOSTTY_SGR_UNDERLINE_DASHED = 5, | ||
| 150 | } GhosttySgrUnderline; | ||
| 151 | |||
| 152 | /** | ||
| 153 | * Unknown SGR attribute data. | ||
| 154 | * | ||
| 155 | * Contains the full parameter list and the partial list where parsing | ||
| 156 | * encountered an unknown or invalid sequence. | ||
| 157 | * | ||
| 158 | * @ingroup sgr | ||
| 159 | */ | ||
| 160 | typedef struct { | ||
| 161 | const uint16_t* full_ptr; | ||
| 162 | size_t full_len; | ||
| 163 | const uint16_t* partial_ptr; | ||
| 164 | size_t partial_len; | ||
| 165 | } GhosttySgrUnknown; | ||
| 166 | |||
| 167 | /** | ||
| 168 | * SGR attribute value union. | ||
| 169 | * | ||
| 170 | * This union contains all possible attribute values. Use the tag field | ||
| 171 | * to determine which union member is active. Attributes without associated | ||
| 172 | * data (like bold, italic) don't use the union value. | ||
| 173 | * | ||
| 174 | * @ingroup sgr | ||
| 175 | */ | ||
| 176 | typedef union { | ||
| 177 | GhosttySgrUnknown unknown; | ||
| 178 | GhosttySgrUnderline underline; | ||
| 179 | GhosttyColorRgb underline_color; | ||
| 180 | GhosttyColorPaletteIndex underline_color_256; | ||
| 181 | GhosttyColorRgb direct_color_fg; | ||
| 182 | GhosttyColorRgb direct_color_bg; | ||
| 183 | GhosttyColorPaletteIndex bg_8; | ||
| 184 | GhosttyColorPaletteIndex fg_8; | ||
| 185 | GhosttyColorPaletteIndex bright_bg_8; | ||
| 186 | GhosttyColorPaletteIndex bright_fg_8; | ||
| 187 | GhosttyColorPaletteIndex bg_256; | ||
| 188 | GhosttyColorPaletteIndex fg_256; | ||
| 189 | uint64_t _padding[8]; | ||
| 190 | } GhosttySgrAttributeValue; | ||
| 191 | |||
| 192 | /** | ||
| 193 | * SGR attribute (tagged union). | ||
| 194 | * | ||
| 195 | * A complete SGR attribute with both its type tag and associated value. | ||
| 196 | * Always check the tag field to determine which value union member is valid. | ||
| 197 | * | ||
| 198 | * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be | ||
| 199 | * identified by tag alone; the value union is not used for these and | ||
| 200 | * the memory in the value field is undefined. | ||
| 201 | * | ||
| 202 | * @ingroup sgr | ||
| 203 | */ | ||
| 204 | typedef struct { | ||
| 205 | GhosttySgrAttributeTag tag; | ||
| 206 | GhosttySgrAttributeValue value; | ||
| 207 | } GhosttySgrAttribute; | ||
| 208 | |||
| 209 | /** | ||
| 210 | * Create a new SGR parser instance. | ||
| 211 | * | ||
| 212 | * Creates a new SGR (Select Graphic Rendition) parser using the provided | ||
| 213 | * allocator. The parser must be freed using ghostty_sgr_free() when | ||
| 214 | * no longer needed. | ||
| 215 | * | ||
| 216 | * @param allocator Pointer to the allocator to use for memory management, or | ||
| 217 | * NULL to use the default allocator | ||
| 218 | * @param parser Pointer to store the created parser handle | ||
| 219 | * @return GHOSTTY_SUCCESS on success, or an error code on failure | ||
| 220 | * | ||
| 221 | * @ingroup sgr | ||
| 222 | */ | ||
| 223 | GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, | ||
| 224 | GhosttySgrParser* parser); | ||
| 225 | |||
| 226 | /** | ||
| 227 | * Free an SGR parser instance. | ||
| 228 | * | ||
| 229 | * Releases all resources associated with the SGR parser. After this call, | ||
| 230 | * the parser handle becomes invalid and must not be used. This includes | ||
| 231 | * any attributes previously returned by ghostty_sgr_next(). | ||
| 232 | * | ||
| 233 | * @param parser The parser handle to free (may be NULL) | ||
| 234 | * | ||
| 235 | * @ingroup sgr | ||
| 236 | */ | ||
| 237 | void ghostty_sgr_free(GhosttySgrParser parser); | ||
| 238 | |||
| 239 | /** | ||
| 240 | * Reset an SGR parser instance to the beginning of the parameter list. | ||
| 241 | * | ||
| 242 | * Resets the parser's iteration state without clearing the parameters. | ||
| 243 | * After calling this, ghostty_sgr_next() will start from the beginning | ||
| 244 | * of the parameter list again. | ||
| 245 | * | ||
| 246 | * @param parser The parser handle to reset, must not be NULL | ||
| 247 | * | ||
| 248 | * @ingroup sgr | ||
| 249 | */ | ||
| 250 | void ghostty_sgr_reset(GhosttySgrParser parser); | ||
| 251 | |||
| 252 | /** | ||
| 253 | * Set SGR parameters for parsing. | ||
| 254 | * | ||
| 255 | * Sets the SGR parameter list to parse. Parameters are the numeric values | ||
| 256 | * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). | ||
| 257 | * | ||
| 258 | * The separators array optionally specifies the separator type for each | ||
| 259 | * parameter position. Each byte should be either ';' for semicolon or ':' | ||
| 260 | * for colon. This is needed for certain color formats that use colon | ||
| 261 | * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator | ||
| 262 | * values are treated as semicolons. The separators array must have the same | ||
| 263 | * length as the params array, if it is not NULL. | ||
| 264 | * | ||
| 265 | * If separators is NULL, all parameters are assumed to be semicolon-separated. | ||
| 266 | * | ||
| 267 | * This function makes an internal copy of the parameter and separator data, | ||
| 268 | * so the caller can safely free or modify the input arrays after this call. | ||
| 269 | * | ||
| 270 | * After calling this function, the parser is automatically reset and ready | ||
| 271 | * to iterate from the beginning. | ||
| 272 | * | ||
| 273 | * @param parser The parser handle, must not be NULL | ||
| 274 | * @param params Array of SGR parameter values | ||
| 275 | * @param separators Optional array of separator characters (';' or ':'), or | ||
| 276 | * NULL | ||
| 277 | * @param len Number of parameters (and separators if provided) | ||
| 278 | * @return GHOSTTY_SUCCESS on success, or an error code on failure | ||
| 279 | * | ||
| 280 | * @ingroup sgr | ||
| 281 | */ | ||
| 282 | GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, | ||
| 283 | const uint16_t* params, | ||
| 284 | const char* separators, | ||
| 285 | size_t len); | ||
| 286 | |||
| 287 | /** | ||
| 288 | * Get the next SGR attribute. | ||
| 289 | * | ||
| 290 | * Parses and returns the next attribute from the parameter list. | ||
| 291 | * Call this function repeatedly until it returns false to process | ||
| 292 | * all attributes in the sequence. | ||
| 293 | * | ||
| 294 | * @param parser The parser handle, must not be NULL | ||
| 295 | * @param attr Pointer to store the next attribute | ||
| 296 | * @return true if an attribute was returned, false if no more attributes | ||
| 297 | * | ||
| 298 | * @ingroup sgr | ||
| 299 | */ | ||
| 300 | bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); | ||
| 301 | |||
| 302 | /** | ||
| 303 | * Get the full parameter list from an unknown SGR attribute. | ||
| 304 | * | ||
| 305 | * This function retrieves the full parameter list that was provided to the | ||
| 306 | * parser when an unknown attribute was encountered. Primarily useful in | ||
| 307 | * WebAssembly environments where accessing struct fields directly is difficult. | ||
| 308 | * | ||
| 309 | * @param unknown The unknown attribute data | ||
| 310 | * @param ptr Pointer to store the pointer to the parameter array (may be NULL) | ||
| 311 | * @return The length of the full parameter array | ||
| 312 | * | ||
| 313 | * @ingroup sgr | ||
| 314 | */ | ||
| 315 | size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, | ||
| 316 | const uint16_t** ptr); | ||
| 317 | |||
| 318 | /** | ||
| 319 | * Get the partial parameter list from an unknown SGR attribute. | ||
| 320 | * | ||
| 321 | * This function retrieves the partial parameter list where parsing stopped | ||
| 322 | * when an unknown attribute was encountered. Primarily useful in WebAssembly | ||
| 323 | * environments where accessing struct fields directly is difficult. | ||
| 324 | * | ||
| 325 | * @param unknown The unknown attribute data | ||
| 326 | * @param ptr Pointer to store the pointer to the parameter array (may be NULL) | ||
| 327 | * @return The length of the partial parameter array | ||
| 328 | * | ||
| 329 | * @ingroup sgr | ||
| 330 | */ | ||
| 331 | size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, | ||
| 332 | const uint16_t** ptr); | ||
| 333 | |||
| 334 | /** | ||
| 335 | * Get the tag from an SGR attribute. | ||
| 336 | * | ||
| 337 | * This function extracts the tag that identifies which type of attribute | ||
| 338 | * this is. Primarily useful in WebAssembly environments where accessing | ||
| 339 | * struct fields directly is difficult. | ||
| 340 | * | ||
| 341 | * @param attr The SGR attribute | ||
| 342 | * @return The attribute tag | ||
| 343 | * | ||
| 344 | * @ingroup sgr | ||
| 345 | */ | ||
| 346 | GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); | ||
| 347 | |||
| 348 | /** | ||
| 349 | * Get the value from an SGR attribute. | ||
| 350 | * | ||
| 351 | * This function returns a pointer to the value union from an SGR attribute. Use | ||
| 352 | * the tag to determine which field of the union is valid. Primarily useful in | ||
| 353 | * WebAssembly environments where accessing struct fields directly is difficult. | ||
| 354 | * | ||
| 355 | * @param attr Pointer to the SGR attribute | ||
| 356 | * @return Pointer to the attribute value union | ||
| 357 | * | ||
| 358 | * @ingroup sgr | ||
| 359 | */ | ||
| 360 | GhosttySgrAttributeValue* ghostty_sgr_attribute_value( | ||
| 361 | GhosttySgrAttribute* attr); | ||
| 362 | |||
| 363 | #ifdef __wasm__ | ||
| 364 | /** | ||
| 365 | * Allocate memory for an SGR attribute (WebAssembly only). | ||
| 366 | * | ||
| 367 | * This is a convenience function for WebAssembly environments to allocate | ||
| 368 | * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. | ||
| 369 | * | ||
| 370 | * @return Pointer to the allocated attribute structure | ||
| 371 | * | ||
| 372 | * @ingroup wasm | ||
| 373 | */ | ||
| 374 | GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); | ||
| 375 | |||
| 376 | /** | ||
| 377 | * Free memory for an SGR attribute (WebAssembly only). | ||
| 378 | * | ||
| 379 | * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. | ||
| 380 | * | ||
| 381 | * @param attr Pointer to the attribute structure to free | ||
| 382 | * | ||
| 383 | * @ingroup wasm | ||
| 384 | */ | ||
| 385 | void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); | ||
| 386 | #endif | ||
| 387 | |||
| 388 | #ifdef __cplusplus | ||
| 389 | } | ||
| 390 | #endif | ||
| 391 | |||
| 392 | /** @} */ | ||
| 393 | |||
| 394 | #endif /* GHOSTTY_VT_SGR_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h created+159| ... | @@ -0,0 +1,159 @@ | ||
| 1 | /** | ||
| 2 | * @file wasm.h | ||
| 3 | * | ||
| 4 | * WebAssembly utility functions for libghostty-vt. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_WASM_H | ||
| 8 | #define GHOSTTY_VT_WASM_H | ||
| 9 | |||
| 10 | #ifdef __wasm__ | ||
| 11 | |||
| 12 | #include <stddef.h> | ||
| 13 | #include <stdint.h> | ||
| 14 | |||
| 15 | /** @defgroup wasm WebAssembly Utilities | ||
| 16 | * | ||
| 17 | * Convenience functions for allocating various types in WebAssembly builds. | ||
| 18 | * **These are only available the libghostty-vt wasm module.** | ||
| 19 | * | ||
| 20 | * Ghostty relies on pointers to various types for ABI compatibility, and | ||
| 21 | * creating those pointers in Wasm can be tedious. These functions provide | ||
| 22 | * a purely additive set of utilities that simplify memory management in | ||
| 23 | * Wasm environments without changing the core C library API. | ||
| 24 | * | ||
| 25 | * @note These functions always use the default allocator. If you need | ||
| 26 | * custom allocation strategies, you should allocate types manually using | ||
| 27 | * your custom allocator. This is a very rare use case in the WebAssembly | ||
| 28 | * world so these are optimized for simplicity. | ||
| 29 | * | ||
| 30 | * ## Example Usage | ||
| 31 | * | ||
| 32 | * Here's a simple example of using the Wasm utilities with the key encoder: | ||
| 33 | * | ||
| 34 | * @code | ||
| 35 | * const { exports } = wasmInstance; | ||
| 36 | * const view = new DataView(wasmMemory.buffer); | ||
| 37 | * | ||
| 38 | * // Create key encoder | ||
| 39 | * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); | ||
| 40 | * exports.ghostty_key_encoder_new(null, encoderPtr); | ||
| 41 | * const encoder = view.getUint32(encoder, true); | ||
| 42 | * | ||
| 43 | * // Configure encoder with Kitty protocol flags | ||
| 44 | * const flagsPtr = exports.ghostty_wasm_alloc_u8(); | ||
| 45 | * view.setUint8(flagsPtr, 0x1F); | ||
| 46 | * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); | ||
| 47 | * | ||
| 48 | * // Allocate output buffer and size pointer | ||
| 49 | * const bufferSize = 32; | ||
| 50 | * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); | ||
| 51 | * const writtenPtr = exports.ghostty_wasm_alloc_usize(); | ||
| 52 | * | ||
| 53 | * // Encode the key event | ||
| 54 | * exports.ghostty_key_encoder_encode( | ||
| 55 | * encoder, eventPtr, bufPtr, bufferSize, writtenPtr | ||
| 56 | * ); | ||
| 57 | * | ||
| 58 | * // Read encoded output | ||
| 59 | * const bytesWritten = view.getUint32(writtenPtr, true); | ||
| 60 | * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); | ||
| 61 | * @endcode | ||
| 62 | * | ||
| 63 | * @remark The code above is pretty ugly! This is the lowest level interface | ||
| 64 | * to the libghostty-vt Wasm module. In practice, this should be wrapped | ||
| 65 | * in a higher-level API that abstracts away all this. | ||
| 66 | * | ||
| 67 | * @{ | ||
| 68 | */ | ||
| 69 | |||
| 70 | /** | ||
| 71 | * Allocate an opaque pointer. This can be used for any opaque pointer | ||
| 72 | * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. | ||
| 73 | * | ||
| 74 | * @return Pointer to allocated opaque pointer, or NULL if allocation failed | ||
| 75 | * @ingroup wasm | ||
| 76 | */ | ||
| 77 | void** ghostty_wasm_alloc_opaque(void); | ||
| 78 | |||
| 79 | /** | ||
| 80 | * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). | ||
| 81 | * | ||
| 82 | * @param ptr Pointer to free, or NULL (NULL is safely ignored) | ||
| 83 | * @ingroup wasm | ||
| 84 | */ | ||
| 85 | void ghostty_wasm_free_opaque(void **ptr); | ||
| 86 | |||
| 87 | /** | ||
| 88 | * Allocate an array of uint8_t values. | ||
| 89 | * | ||
| 90 | * @param len Number of uint8_t elements to allocate | ||
| 91 | * @return Pointer to allocated array, or NULL if allocation failed | ||
| 92 | * @ingroup wasm | ||
| 93 | */ | ||
| 94 | uint8_t* ghostty_wasm_alloc_u8_array(size_t len); | ||
| 95 | |||
| 96 | /** | ||
| 97 | * Free an array allocated by ghostty_wasm_alloc_u8_array(). | ||
| 98 | * | ||
| 99 | * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) | ||
| 100 | * @param len Length of the array (must match the length passed to alloc) | ||
| 101 | * @ingroup wasm | ||
| 102 | */ | ||
| 103 | void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); | ||
| 104 | |||
| 105 | /** | ||
| 106 | * Allocate an array of uint16_t values. | ||
| 107 | * | ||
| 108 | * @param len Number of uint16_t elements to allocate | ||
| 109 | * @return Pointer to allocated array, or NULL if allocation failed | ||
| 110 | * @ingroup wasm | ||
| 111 | */ | ||
| 112 | uint16_t* ghostty_wasm_alloc_u16_array(size_t len); | ||
| 113 | |||
| 114 | /** | ||
| 115 | * Free an array allocated by ghostty_wasm_alloc_u16_array(). | ||
| 116 | * | ||
| 117 | * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) | ||
| 118 | * @param len Length of the array (must match the length passed to alloc) | ||
| 119 | * @ingroup wasm | ||
| 120 | */ | ||
| 121 | void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); | ||
| 122 | |||
| 123 | /** | ||
| 124 | * Allocate a single uint8_t value. | ||
| 125 | * | ||
| 126 | * @return Pointer to allocated uint8_t, or NULL if allocation failed | ||
| 127 | * @ingroup wasm | ||
| 128 | */ | ||
| 129 | uint8_t* ghostty_wasm_alloc_u8(void); | ||
| 130 | |||
| 131 | /** | ||
| 132 | * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). | ||
| 133 | * | ||
| 134 | * @param ptr Pointer to free, or NULL (NULL is safely ignored) | ||
| 135 | * @ingroup wasm | ||
| 136 | */ | ||
| 137 | void ghostty_wasm_free_u8(uint8_t *ptr); | ||
| 138 | |||
| 139 | /** | ||
| 140 | * Allocate a single size_t value. | ||
| 141 | * | ||
| 142 | * @return Pointer to allocated size_t, or NULL if allocation failed | ||
| 143 | * @ingroup wasm | ||
| 144 | */ | ||
| 145 | size_t* ghostty_wasm_alloc_usize(void); | ||
| 146 | |||
| 147 | /** | ||
| 148 | * Free a size_t allocated by ghostty_wasm_alloc_usize(). | ||
| 149 | * | ||
| 150 | * @param ptr Pointer to free, or NULL (NULL is safely ignored) | ||
| 151 | * @ingroup wasm | ||
| 152 | */ | ||
| 153 | void ghostty_wasm_free_usize(size_t *ptr); | ||
| 154 | |||
| 155 | /** @} */ | ||
| 156 | |||
| 157 | #endif /* __wasm__ */ | ||
| 158 | |||
| 159 | #endif /* GHOSTTY_VT_WASM_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/module.modulemap created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | // This makes Ghostty available to the XCode build for the macOS app. | ||
| 2 | // We append "Kit" to it not to be cute, but because targets have to have | ||
| 3 | // unique names and we use Ghostty for other things. | ||
| 4 | module GhosttyKit { | ||
| 5 | umbrella header "ghostty.h" | ||
| 6 | export * | ||
| 7 | } | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a created| Binary files /dev/null and b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a differ | |||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h created+1218| ... | @@ -0,0 +1,1218 @@ | ||
| 1 | // Ghostty embedding API. The documentation for the embedding API is | ||
| 2 | // only within the Zig source files that define the implementations. This | ||
| 3 | // isn't meant to be a general purpose embedding API (yet) so there hasn't | ||
| 4 | // been documentation or example work beyond that. | ||
| 5 | // | ||
| 6 | // The only consumer of this API is the macOS app, but the API is built to | ||
| 7 | // be more general purpose. | ||
| 8 | #ifndef GHOSTTY_H | ||
| 9 | #define GHOSTTY_H | ||
| 10 | |||
| 11 | #ifdef __cplusplus | ||
| 12 | extern "C" { | ||
| 13 | #endif | ||
| 14 | |||
| 15 | #include <stdbool.h> | ||
| 16 | #include <stddef.h> | ||
| 17 | #include <stdint.h> | ||
| 18 | |||
| 19 | #ifdef _MSC_VER | ||
| 20 | #include <BaseTsd.h> | ||
| 21 | typedef SSIZE_T ssize_t; | ||
| 22 | #else | ||
| 23 | #include <sys/types.h> | ||
| 24 | #endif | ||
| 25 | |||
| 26 | //------------------------------------------------------------------- | ||
| 27 | // Macros | ||
| 28 | |||
| 29 | #define GHOSTTY_SUCCESS 0 | ||
| 30 | |||
| 31 | // Symbol visibility for shared library builds. On Windows, functions | ||
| 32 | // are exported from the DLL when building and imported when consuming. | ||
| 33 | // On other platforms with GCC/Clang, functions are marked with default | ||
| 34 | // visibility so they remain accessible when the library is built with | ||
| 35 | // -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC | ||
| 36 | // before including this header to make this a no-op. | ||
| 37 | #ifndef GHOSTTY_API | ||
| 38 | #if defined(GHOSTTY_STATIC) | ||
| 39 | #define GHOSTTY_API | ||
| 40 | #elif defined(_WIN32) || defined(_WIN64) | ||
| 41 | #ifdef GHOSTTY_BUILD_SHARED | ||
| 42 | #define GHOSTTY_API __declspec(dllexport) | ||
| 43 | #else | ||
| 44 | #define GHOSTTY_API __declspec(dllimport) | ||
| 45 | #endif | ||
| 46 | #elif defined(__GNUC__) && __GNUC__ >= 4 | ||
| 47 | #define GHOSTTY_API __attribute__((visibility("default"))) | ||
| 48 | #else | ||
| 49 | #define GHOSTTY_API | ||
| 50 | #endif | ||
| 51 | #endif | ||
| 52 | |||
| 53 | //------------------------------------------------------------------- | ||
| 54 | // Types | ||
| 55 | |||
| 56 | // Opaque types | ||
| 57 | typedef void* ghostty_app_t; | ||
| 58 | typedef void* ghostty_config_t; | ||
| 59 | typedef void* ghostty_surface_t; | ||
| 60 | typedef void* ghostty_inspector_t; | ||
| 61 | |||
| 62 | // All the types below are fully defined and must be kept in sync with | ||
| 63 | // their Zig counterparts. Any changes to these types MUST have an associated | ||
| 64 | // Zig change. | ||
| 65 | typedef enum { | ||
| 66 | GHOSTTY_PLATFORM_INVALID, | ||
| 67 | GHOSTTY_PLATFORM_MACOS, | ||
| 68 | GHOSTTY_PLATFORM_IOS, | ||
| 69 | } ghostty_platform_e; | ||
| 70 | |||
| 71 | // Callback for custom I/O write handler. | ||
| 72 | typedef void (*ghostty_surface_write_fn)(void* userdata, | ||
| 73 | const uint8_t* data, | ||
| 74 | size_t len); | ||
| 75 | |||
| 76 | typedef enum { | ||
| 77 | GHOSTTY_CLIPBOARD_STANDARD, | ||
| 78 | GHOSTTY_CLIPBOARD_SELECTION, | ||
| 79 | } ghostty_clipboard_e; | ||
| 80 | |||
| 81 | typedef struct { | ||
| 82 | const char *mime; | ||
| 83 | const char *data; | ||
| 84 | } ghostty_clipboard_content_s; | ||
| 85 | |||
| 86 | typedef enum { | ||
| 87 | GHOSTTY_CLIPBOARD_REQUEST_PASTE, | ||
| 88 | GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, | ||
| 89 | GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, | ||
| 90 | } ghostty_clipboard_request_e; | ||
| 91 | |||
| 92 | typedef enum { | ||
| 93 | GHOSTTY_MOUSE_RELEASE, | ||
| 94 | GHOSTTY_MOUSE_PRESS, | ||
| 95 | } ghostty_input_mouse_state_e; | ||
| 96 | |||
| 97 | typedef enum { | ||
| 98 | GHOSTTY_MOUSE_UNKNOWN, | ||
| 99 | GHOSTTY_MOUSE_LEFT, | ||
| 100 | GHOSTTY_MOUSE_RIGHT, | ||
| 101 | GHOSTTY_MOUSE_MIDDLE, | ||
| 102 | GHOSTTY_MOUSE_FOUR, | ||
| 103 | GHOSTTY_MOUSE_FIVE, | ||
| 104 | GHOSTTY_MOUSE_SIX, | ||
| 105 | GHOSTTY_MOUSE_SEVEN, | ||
| 106 | GHOSTTY_MOUSE_EIGHT, | ||
| 107 | GHOSTTY_MOUSE_NINE, | ||
| 108 | GHOSTTY_MOUSE_TEN, | ||
| 109 | GHOSTTY_MOUSE_ELEVEN, | ||
| 110 | } ghostty_input_mouse_button_e; | ||
| 111 | |||
| 112 | typedef enum { | ||
| 113 | GHOSTTY_MOUSE_MOMENTUM_NONE, | ||
| 114 | GHOSTTY_MOUSE_MOMENTUM_BEGAN, | ||
| 115 | GHOSTTY_MOUSE_MOMENTUM_STATIONARY, | ||
| 116 | GHOSTTY_MOUSE_MOMENTUM_CHANGED, | ||
| 117 | GHOSTTY_MOUSE_MOMENTUM_ENDED, | ||
| 118 | GHOSTTY_MOUSE_MOMENTUM_CANCELLED, | ||
| 119 | GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN, | ||
| 120 | } ghostty_input_mouse_momentum_e; | ||
| 121 | |||
| 122 | typedef enum { | ||
| 123 | GHOSTTY_COLOR_SCHEME_LIGHT = 0, | ||
| 124 | GHOSTTY_COLOR_SCHEME_DARK = 1, | ||
| 125 | } ghostty_color_scheme_e; | ||
| 126 | |||
| 127 | // This is a packed struct (see src/input/mouse.zig) but the C standard | ||
| 128 | // afaik doesn't let us reliably define packed structs so we build it up | ||
| 129 | // from scratch. | ||
| 130 | typedef int ghostty_input_scroll_mods_t; | ||
| 131 | |||
| 132 | typedef enum { | ||
| 133 | GHOSTTY_MODS_NONE = 0, | ||
| 134 | GHOSTTY_MODS_SHIFT = 1 << 0, | ||
| 135 | GHOSTTY_MODS_CTRL = 1 << 1, | ||
| 136 | GHOSTTY_MODS_ALT = 1 << 2, | ||
| 137 | GHOSTTY_MODS_SUPER = 1 << 3, | ||
| 138 | GHOSTTY_MODS_CAPS = 1 << 4, | ||
| 139 | GHOSTTY_MODS_NUM = 1 << 5, | ||
| 140 | GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6, | ||
| 141 | GHOSTTY_MODS_CTRL_RIGHT = 1 << 7, | ||
| 142 | GHOSTTY_MODS_ALT_RIGHT = 1 << 8, | ||
| 143 | GHOSTTY_MODS_SUPER_RIGHT = 1 << 9, | ||
| 144 | } ghostty_input_mods_e; | ||
| 145 | |||
| 146 | typedef enum { | ||
| 147 | GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0, | ||
| 148 | GHOSTTY_BINDING_FLAGS_ALL = 1 << 1, | ||
| 149 | GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2, | ||
| 150 | GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3, | ||
| 151 | } ghostty_binding_flags_e; | ||
| 152 | |||
| 153 | typedef enum { | ||
| 154 | GHOSTTY_ACTION_RELEASE, | ||
| 155 | GHOSTTY_ACTION_PRESS, | ||
| 156 | GHOSTTY_ACTION_REPEAT, | ||
| 157 | } ghostty_input_action_e; | ||
| 158 | |||
| 159 | // Based on: https://www.w3.org/TR/uievents-code/ | ||
| 160 | typedef enum { | ||
| 161 | GHOSTTY_KEY_UNIDENTIFIED, | ||
| 162 | |||
| 163 | // "Writing System Keys" § 3.1.1 | ||
| 164 | GHOSTTY_KEY_BACKQUOTE, | ||
| 165 | GHOSTTY_KEY_BACKSLASH, | ||
| 166 | GHOSTTY_KEY_BRACKET_LEFT, | ||
| 167 | GHOSTTY_KEY_BRACKET_RIGHT, | ||
| 168 | GHOSTTY_KEY_COMMA, | ||
| 169 | GHOSTTY_KEY_DIGIT_0, | ||
| 170 | GHOSTTY_KEY_DIGIT_1, | ||
| 171 | GHOSTTY_KEY_DIGIT_2, | ||
| 172 | GHOSTTY_KEY_DIGIT_3, | ||
| 173 | GHOSTTY_KEY_DIGIT_4, | ||
| 174 | GHOSTTY_KEY_DIGIT_5, | ||
| 175 | GHOSTTY_KEY_DIGIT_6, | ||
| 176 | GHOSTTY_KEY_DIGIT_7, | ||
| 177 | GHOSTTY_KEY_DIGIT_8, | ||
| 178 | GHOSTTY_KEY_DIGIT_9, | ||
| 179 | GHOSTTY_KEY_EQUAL, | ||
| 180 | GHOSTTY_KEY_INTL_BACKSLASH, | ||
| 181 | GHOSTTY_KEY_INTL_RO, | ||
| 182 | GHOSTTY_KEY_INTL_YEN, | ||
| 183 | GHOSTTY_KEY_A, | ||
| 184 | GHOSTTY_KEY_B, | ||
| 185 | GHOSTTY_KEY_C, | ||
| 186 | GHOSTTY_KEY_D, | ||
| 187 | GHOSTTY_KEY_E, | ||
| 188 | GHOSTTY_KEY_F, | ||
| 189 | GHOSTTY_KEY_G, | ||
| 190 | GHOSTTY_KEY_H, | ||
| 191 | GHOSTTY_KEY_I, | ||
| 192 | GHOSTTY_KEY_J, | ||
| 193 | GHOSTTY_KEY_K, | ||
| 194 | GHOSTTY_KEY_L, | ||
| 195 | GHOSTTY_KEY_M, | ||
| 196 | GHOSTTY_KEY_N, | ||
| 197 | GHOSTTY_KEY_O, | ||
| 198 | GHOSTTY_KEY_P, | ||
| 199 | GHOSTTY_KEY_Q, | ||
| 200 | GHOSTTY_KEY_R, | ||
| 201 | GHOSTTY_KEY_S, | ||
| 202 | GHOSTTY_KEY_T, | ||
| 203 | GHOSTTY_KEY_U, | ||
| 204 | GHOSTTY_KEY_V, | ||
| 205 | GHOSTTY_KEY_W, | ||
| 206 | GHOSTTY_KEY_X, | ||
| 207 | GHOSTTY_KEY_Y, | ||
| 208 | GHOSTTY_KEY_Z, | ||
| 209 | GHOSTTY_KEY_MINUS, | ||
| 210 | GHOSTTY_KEY_PERIOD, | ||
| 211 | GHOSTTY_KEY_QUOTE, | ||
| 212 | GHOSTTY_KEY_SEMICOLON, | ||
| 213 | GHOSTTY_KEY_SLASH, | ||
| 214 | |||
| 215 | // "Functional Keys" § 3.1.2 | ||
| 216 | GHOSTTY_KEY_ALT_LEFT, | ||
| 217 | GHOSTTY_KEY_ALT_RIGHT, | ||
| 218 | GHOSTTY_KEY_BACKSPACE, | ||
| 219 | GHOSTTY_KEY_CAPS_LOCK, | ||
| 220 | GHOSTTY_KEY_CONTEXT_MENU, | ||
| 221 | GHOSTTY_KEY_CONTROL_LEFT, | ||
| 222 | GHOSTTY_KEY_CONTROL_RIGHT, | ||
| 223 | GHOSTTY_KEY_ENTER, | ||
| 224 | GHOSTTY_KEY_META_LEFT, | ||
| 225 | GHOSTTY_KEY_META_RIGHT, | ||
| 226 | GHOSTTY_KEY_SHIFT_LEFT, | ||
| 227 | GHOSTTY_KEY_SHIFT_RIGHT, | ||
| 228 | GHOSTTY_KEY_SPACE, | ||
| 229 | GHOSTTY_KEY_TAB, | ||
| 230 | GHOSTTY_KEY_CONVERT, | ||
| 231 | GHOSTTY_KEY_KANA_MODE, | ||
| 232 | GHOSTTY_KEY_NON_CONVERT, | ||
| 233 | |||
| 234 | // "Control Pad Section" § 3.2 | ||
| 235 | GHOSTTY_KEY_DELETE, | ||
| 236 | GHOSTTY_KEY_END, | ||
| 237 | GHOSTTY_KEY_HELP, | ||
| 238 | GHOSTTY_KEY_HOME, | ||
| 239 | GHOSTTY_KEY_INSERT, | ||
| 240 | GHOSTTY_KEY_PAGE_DOWN, | ||
| 241 | GHOSTTY_KEY_PAGE_UP, | ||
| 242 | |||
| 243 | // "Arrow Pad Section" § 3.3 | ||
| 244 | GHOSTTY_KEY_ARROW_DOWN, | ||
| 245 | GHOSTTY_KEY_ARROW_LEFT, | ||
| 246 | GHOSTTY_KEY_ARROW_RIGHT, | ||
| 247 | GHOSTTY_KEY_ARROW_UP, | ||
| 248 | |||
| 249 | // "Numpad Section" § 3.4 | ||
| 250 | GHOSTTY_KEY_NUM_LOCK, | ||
| 251 | GHOSTTY_KEY_NUMPAD_0, | ||
| 252 | GHOSTTY_KEY_NUMPAD_1, | ||
| 253 | GHOSTTY_KEY_NUMPAD_2, | ||
| 254 | GHOSTTY_KEY_NUMPAD_3, | ||
| 255 | GHOSTTY_KEY_NUMPAD_4, | ||
| 256 | GHOSTTY_KEY_NUMPAD_5, | ||
| 257 | GHOSTTY_KEY_NUMPAD_6, | ||
| 258 | GHOSTTY_KEY_NUMPAD_7, | ||
| 259 | GHOSTTY_KEY_NUMPAD_8, | ||
| 260 | GHOSTTY_KEY_NUMPAD_9, | ||
| 261 | GHOSTTY_KEY_NUMPAD_ADD, | ||
| 262 | GHOSTTY_KEY_NUMPAD_BACKSPACE, | ||
| 263 | GHOSTTY_KEY_NUMPAD_CLEAR, | ||
| 264 | GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, | ||
| 265 | GHOSTTY_KEY_NUMPAD_COMMA, | ||
| 266 | GHOSTTY_KEY_NUMPAD_DECIMAL, | ||
| 267 | GHOSTTY_KEY_NUMPAD_DIVIDE, | ||
| 268 | GHOSTTY_KEY_NUMPAD_ENTER, | ||
| 269 | GHOSTTY_KEY_NUMPAD_EQUAL, | ||
| 270 | GHOSTTY_KEY_NUMPAD_MEMORY_ADD, | ||
| 271 | GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, | ||
| 272 | GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, | ||
| 273 | GHOSTTY_KEY_NUMPAD_MEMORY_STORE, | ||
| 274 | GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, | ||
| 275 | GHOSTTY_KEY_NUMPAD_MULTIPLY, | ||
| 276 | GHOSTTY_KEY_NUMPAD_PAREN_LEFT, | ||
| 277 | GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, | ||
| 278 | GHOSTTY_KEY_NUMPAD_SUBTRACT, | ||
| 279 | GHOSTTY_KEY_NUMPAD_SEPARATOR, | ||
| 280 | GHOSTTY_KEY_NUMPAD_UP, | ||
| 281 | GHOSTTY_KEY_NUMPAD_DOWN, | ||
| 282 | GHOSTTY_KEY_NUMPAD_RIGHT, | ||
| 283 | GHOSTTY_KEY_NUMPAD_LEFT, | ||
| 284 | GHOSTTY_KEY_NUMPAD_BEGIN, | ||
| 285 | GHOSTTY_KEY_NUMPAD_HOME, | ||
| 286 | GHOSTTY_KEY_NUMPAD_END, | ||
| 287 | GHOSTTY_KEY_NUMPAD_INSERT, | ||
| 288 | GHOSTTY_KEY_NUMPAD_DELETE, | ||
| 289 | GHOSTTY_KEY_NUMPAD_PAGE_UP, | ||
| 290 | GHOSTTY_KEY_NUMPAD_PAGE_DOWN, | ||
| 291 | |||
| 292 | // "Function Section" § 3.5 | ||
| 293 | GHOSTTY_KEY_ESCAPE, | ||
| 294 | GHOSTTY_KEY_F1, | ||
| 295 | GHOSTTY_KEY_F2, | ||
| 296 | GHOSTTY_KEY_F3, | ||
| 297 | GHOSTTY_KEY_F4, | ||
| 298 | GHOSTTY_KEY_F5, | ||
| 299 | GHOSTTY_KEY_F6, | ||
| 300 | GHOSTTY_KEY_F7, | ||
| 301 | GHOSTTY_KEY_F8, | ||
| 302 | GHOSTTY_KEY_F9, | ||
| 303 | GHOSTTY_KEY_F10, | ||
| 304 | GHOSTTY_KEY_F11, | ||
| 305 | GHOSTTY_KEY_F12, | ||
| 306 | GHOSTTY_KEY_F13, | ||
| 307 | GHOSTTY_KEY_F14, | ||
| 308 | GHOSTTY_KEY_F15, | ||
| 309 | GHOSTTY_KEY_F16, | ||
| 310 | GHOSTTY_KEY_F17, | ||
| 311 | GHOSTTY_KEY_F18, | ||
| 312 | GHOSTTY_KEY_F19, | ||
| 313 | GHOSTTY_KEY_F20, | ||
| 314 | GHOSTTY_KEY_F21, | ||
| 315 | GHOSTTY_KEY_F22, | ||
| 316 | GHOSTTY_KEY_F23, | ||
| 317 | GHOSTTY_KEY_F24, | ||
| 318 | GHOSTTY_KEY_F25, | ||
| 319 | GHOSTTY_KEY_FN, | ||
| 320 | GHOSTTY_KEY_FN_LOCK, | ||
| 321 | GHOSTTY_KEY_PRINT_SCREEN, | ||
| 322 | GHOSTTY_KEY_SCROLL_LOCK, | ||
| 323 | GHOSTTY_KEY_PAUSE, | ||
| 324 | |||
| 325 | // "Media Keys" § 3.6 | ||
| 326 | GHOSTTY_KEY_BROWSER_BACK, | ||
| 327 | GHOSTTY_KEY_BROWSER_FAVORITES, | ||
| 328 | GHOSTTY_KEY_BROWSER_FORWARD, | ||
| 329 | GHOSTTY_KEY_BROWSER_HOME, | ||
| 330 | GHOSTTY_KEY_BROWSER_REFRESH, | ||
| 331 | GHOSTTY_KEY_BROWSER_SEARCH, | ||
| 332 | GHOSTTY_KEY_BROWSER_STOP, | ||
| 333 | GHOSTTY_KEY_EJECT, | ||
| 334 | GHOSTTY_KEY_LAUNCH_APP_1, | ||
| 335 | GHOSTTY_KEY_LAUNCH_APP_2, | ||
| 336 | GHOSTTY_KEY_LAUNCH_MAIL, | ||
| 337 | GHOSTTY_KEY_MEDIA_PLAY_PAUSE, | ||
| 338 | GHOSTTY_KEY_MEDIA_SELECT, | ||
| 339 | GHOSTTY_KEY_MEDIA_STOP, | ||
| 340 | GHOSTTY_KEY_MEDIA_TRACK_NEXT, | ||
| 341 | GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, | ||
| 342 | GHOSTTY_KEY_POWER, | ||
| 343 | GHOSTTY_KEY_SLEEP, | ||
| 344 | GHOSTTY_KEY_AUDIO_VOLUME_DOWN, | ||
| 345 | GHOSTTY_KEY_AUDIO_VOLUME_MUTE, | ||
| 346 | GHOSTTY_KEY_AUDIO_VOLUME_UP, | ||
| 347 | GHOSTTY_KEY_WAKE_UP, | ||
| 348 | |||
| 349 | // "Legacy, Non-standard, and Special Keys" § 3.7 | ||
| 350 | GHOSTTY_KEY_COPY, | ||
| 351 | GHOSTTY_KEY_CUT, | ||
| 352 | GHOSTTY_KEY_PASTE, | ||
| 353 | } ghostty_input_key_e; | ||
| 354 | |||
| 355 | typedef struct { | ||
| 356 | ghostty_input_action_e action; | ||
| 357 | ghostty_input_mods_e mods; | ||
| 358 | ghostty_input_mods_e consumed_mods; | ||
| 359 | uint32_t keycode; | ||
| 360 | const char* text; | ||
| 361 | uint32_t unshifted_codepoint; | ||
| 362 | bool composing; | ||
| 363 | } ghostty_input_key_s; | ||
| 364 | |||
| 365 | typedef enum { | ||
| 366 | GHOSTTY_TRIGGER_PHYSICAL, | ||
| 367 | GHOSTTY_TRIGGER_UNICODE, | ||
| 368 | GHOSTTY_TRIGGER_CATCH_ALL, | ||
| 369 | } ghostty_input_trigger_tag_e; | ||
| 370 | |||
| 371 | typedef union { | ||
| 372 | ghostty_input_key_e translated; | ||
| 373 | ghostty_input_key_e physical; | ||
| 374 | uint32_t unicode; | ||
| 375 | // catch_all has no payload | ||
| 376 | } ghostty_input_trigger_key_u; | ||
| 377 | |||
| 378 | typedef struct { | ||
| 379 | ghostty_input_trigger_tag_e tag; | ||
| 380 | ghostty_input_trigger_key_u key; | ||
| 381 | ghostty_input_mods_e mods; | ||
| 382 | } ghostty_input_trigger_s; | ||
| 383 | |||
| 384 | typedef struct { | ||
| 385 | const char* action_key; | ||
| 386 | const char* action; | ||
| 387 | const char* title; | ||
| 388 | const char* description; | ||
| 389 | } ghostty_command_s; | ||
| 390 | |||
| 391 | typedef enum { | ||
| 392 | GHOSTTY_BUILD_MODE_DEBUG, | ||
| 393 | GHOSTTY_BUILD_MODE_RELEASE_SAFE, | ||
| 394 | GHOSTTY_BUILD_MODE_RELEASE_FAST, | ||
| 395 | GHOSTTY_BUILD_MODE_RELEASE_SMALL, | ||
| 396 | } ghostty_build_mode_e; | ||
| 397 | |||
| 398 | typedef struct { | ||
| 399 | ghostty_build_mode_e build_mode; | ||
| 400 | const char* version; | ||
| 401 | uintptr_t version_len; | ||
| 402 | } ghostty_info_s; | ||
| 403 | |||
| 404 | typedef struct { | ||
| 405 | const char* message; | ||
| 406 | } ghostty_diagnostic_s; | ||
| 407 | |||
| 408 | typedef struct { | ||
| 409 | const char* ptr; | ||
| 410 | uintptr_t len; | ||
| 411 | bool sentinel; | ||
| 412 | } ghostty_string_s; | ||
| 413 | |||
| 414 | typedef struct { | ||
| 415 | double tl_px_x; | ||
| 416 | double tl_px_y; | ||
| 417 | uint32_t offset_start; | ||
| 418 | uint32_t offset_len; | ||
| 419 | const char* text; | ||
| 420 | uintptr_t text_len; | ||
| 421 | } ghostty_text_s; | ||
| 422 | |||
| 423 | typedef enum { | ||
| 424 | GHOSTTY_POINT_ACTIVE, | ||
| 425 | GHOSTTY_POINT_VIEWPORT, | ||
| 426 | GHOSTTY_POINT_SCREEN, | ||
| 427 | GHOSTTY_POINT_SURFACE, | ||
| 428 | } ghostty_point_tag_e; | ||
| 429 | |||
| 430 | typedef enum { | ||
| 431 | GHOSTTY_POINT_COORD_EXACT, | ||
| 432 | GHOSTTY_POINT_COORD_TOP_LEFT, | ||
| 433 | GHOSTTY_POINT_COORD_BOTTOM_RIGHT, | ||
| 434 | } ghostty_point_coord_e; | ||
| 435 | |||
| 436 | typedef struct { | ||
| 437 | ghostty_point_tag_e tag; | ||
| 438 | ghostty_point_coord_e coord; | ||
| 439 | uint32_t x; | ||
| 440 | uint32_t y; | ||
| 441 | } ghostty_point_s; | ||
| 442 | |||
| 443 | typedef struct { | ||
| 444 | ghostty_point_s top_left; | ||
| 445 | ghostty_point_s bottom_right; | ||
| 446 | bool rectangle; | ||
| 447 | } ghostty_selection_s; | ||
| 448 | |||
| 449 | typedef struct { | ||
| 450 | const char* key; | ||
| 451 | const char* value; | ||
| 452 | } ghostty_env_var_s; | ||
| 453 | |||
| 454 | typedef struct { | ||
| 455 | void* nsview; | ||
| 456 | } ghostty_platform_macos_s; | ||
| 457 | |||
| 458 | typedef struct { | ||
| 459 | void* uiview; | ||
| 460 | } ghostty_platform_ios_s; | ||
| 461 | |||
| 462 | typedef union { | ||
| 463 | ghostty_platform_macos_s macos; | ||
| 464 | ghostty_platform_ios_s ios; | ||
| 465 | } ghostty_platform_u; | ||
| 466 | |||
| 467 | typedef enum { | ||
| 468 | GHOSTTY_SURFACE_CONTEXT_WINDOW = 0, | ||
| 469 | GHOSTTY_SURFACE_CONTEXT_TAB = 1, | ||
| 470 | GHOSTTY_SURFACE_CONTEXT_SPLIT = 2, | ||
| 471 | } ghostty_surface_context_e; | ||
| 472 | |||
| 473 | typedef struct { | ||
| 474 | ghostty_platform_e platform_tag; | ||
| 475 | ghostty_platform_u platform; | ||
| 476 | void* userdata; | ||
| 477 | double scale_factor; | ||
| 478 | float font_size; | ||
| 479 | const char* working_directory; | ||
| 480 | const char* command; | ||
| 481 | ghostty_env_var_s* env_vars; | ||
| 482 | size_t env_var_count; | ||
| 483 | const char* initial_input; | ||
| 484 | bool wait_after_command; | ||
| 485 | ghostty_surface_context_e context; | ||
| 486 | bool use_custom_io; | ||
| 487 | } ghostty_surface_config_s; | ||
| 488 | |||
| 489 | typedef struct { | ||
| 490 | uint16_t columns; | ||
| 491 | uint16_t rows; | ||
| 492 | uint32_t width_px; | ||
| 493 | uint32_t height_px; | ||
| 494 | uint32_t cell_width_px; | ||
| 495 | uint32_t cell_height_px; | ||
| 496 | } ghostty_surface_size_s; | ||
| 497 | |||
| 498 | // Config types | ||
| 499 | |||
| 500 | // config.Path | ||
| 501 | typedef struct { | ||
| 502 | const char* path; | ||
| 503 | bool optional; | ||
| 504 | } ghostty_config_path_s; | ||
| 505 | |||
| 506 | // config.Color | ||
| 507 | typedef struct { | ||
| 508 | uint8_t r; | ||
| 509 | uint8_t g; | ||
| 510 | uint8_t b; | ||
| 511 | } ghostty_config_color_s; | ||
| 512 | |||
| 513 | // config.ColorList | ||
| 514 | typedef struct { | ||
| 515 | const ghostty_config_color_s* colors; | ||
| 516 | size_t len; | ||
| 517 | } ghostty_config_color_list_s; | ||
| 518 | |||
| 519 | // config.RepeatableCommand | ||
| 520 | typedef struct { | ||
| 521 | const ghostty_command_s* commands; | ||
| 522 | size_t len; | ||
| 523 | } ghostty_config_command_list_s; | ||
| 524 | |||
| 525 | // config.Palette | ||
| 526 | typedef struct { | ||
| 527 | ghostty_config_color_s colors[256]; | ||
| 528 | } ghostty_config_palette_s; | ||
| 529 | |||
| 530 | // config.QuickTerminalSize | ||
| 531 | typedef enum { | ||
| 532 | GHOSTTY_QUICK_TERMINAL_SIZE_NONE, | ||
| 533 | GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE, | ||
| 534 | GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS, | ||
| 535 | } ghostty_quick_terminal_size_tag_e; | ||
| 536 | |||
| 537 | typedef union { | ||
| 538 | float percentage; | ||
| 539 | uint32_t pixels; | ||
| 540 | } ghostty_quick_terminal_size_value_u; | ||
| 541 | |||
| 542 | typedef struct { | ||
| 543 | ghostty_quick_terminal_size_tag_e tag; | ||
| 544 | ghostty_quick_terminal_size_value_u value; | ||
| 545 | } ghostty_quick_terminal_size_s; | ||
| 546 | |||
| 547 | typedef struct { | ||
| 548 | ghostty_quick_terminal_size_s primary; | ||
| 549 | ghostty_quick_terminal_size_s secondary; | ||
| 550 | } ghostty_config_quick_terminal_size_s; | ||
| 551 | |||
| 552 | // config.Fullscreen | ||
| 553 | typedef enum { | ||
| 554 | GHOSTTY_CONFIG_FULLSCREEN_FALSE, | ||
| 555 | GHOSTTY_CONFIG_FULLSCREEN_TRUE, | ||
| 556 | GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE, | ||
| 557 | GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, | ||
| 558 | GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, | ||
| 559 | } ghostty_config_fullscreen_e; | ||
| 560 | |||
| 561 | // apprt.Target.Key | ||
| 562 | typedef enum { | ||
| 563 | GHOSTTY_TARGET_APP, | ||
| 564 | GHOSTTY_TARGET_SURFACE, | ||
| 565 | } ghostty_target_tag_e; | ||
| 566 | |||
| 567 | typedef union { | ||
| 568 | ghostty_surface_t surface; | ||
| 569 | } ghostty_target_u; | ||
| 570 | |||
| 571 | typedef struct { | ||
| 572 | ghostty_target_tag_e tag; | ||
| 573 | ghostty_target_u target; | ||
| 574 | } ghostty_target_s; | ||
| 575 | |||
| 576 | // apprt.action.SplitDirection | ||
| 577 | typedef enum { | ||
| 578 | GHOSTTY_SPLIT_DIRECTION_RIGHT, | ||
| 579 | GHOSTTY_SPLIT_DIRECTION_DOWN, | ||
| 580 | GHOSTTY_SPLIT_DIRECTION_LEFT, | ||
| 581 | GHOSTTY_SPLIT_DIRECTION_UP, | ||
| 582 | } ghostty_action_split_direction_e; | ||
| 583 | |||
| 584 | // apprt.action.GotoSplit | ||
| 585 | typedef enum { | ||
| 586 | GHOSTTY_GOTO_SPLIT_PREVIOUS, | ||
| 587 | GHOSTTY_GOTO_SPLIT_NEXT, | ||
| 588 | GHOSTTY_GOTO_SPLIT_UP, | ||
| 589 | GHOSTTY_GOTO_SPLIT_LEFT, | ||
| 590 | GHOSTTY_GOTO_SPLIT_DOWN, | ||
| 591 | GHOSTTY_GOTO_SPLIT_RIGHT, | ||
| 592 | } ghostty_action_goto_split_e; | ||
| 593 | |||
| 594 | // apprt.action.GotoWindow | ||
| 595 | typedef enum { | ||
| 596 | GHOSTTY_GOTO_WINDOW_PREVIOUS, | ||
| 597 | GHOSTTY_GOTO_WINDOW_NEXT, | ||
| 598 | } ghostty_action_goto_window_e; | ||
| 599 | |||
| 600 | // apprt.action.ResizeSplit.Direction | ||
| 601 | typedef enum { | ||
| 602 | GHOSTTY_RESIZE_SPLIT_UP, | ||
| 603 | GHOSTTY_RESIZE_SPLIT_DOWN, | ||
| 604 | GHOSTTY_RESIZE_SPLIT_LEFT, | ||
| 605 | GHOSTTY_RESIZE_SPLIT_RIGHT, | ||
| 606 | } ghostty_action_resize_split_direction_e; | ||
| 607 | |||
| 608 | // apprt.action.ResizeSplit | ||
| 609 | typedef struct { | ||
| 610 | uint16_t amount; | ||
| 611 | ghostty_action_resize_split_direction_e direction; | ||
| 612 | } ghostty_action_resize_split_s; | ||
| 613 | |||
| 614 | // apprt.action.MoveTab | ||
| 615 | typedef struct { | ||
| 616 | ssize_t amount; | ||
| 617 | } ghostty_action_move_tab_s; | ||
| 618 | |||
| 619 | // apprt.action.GotoTab | ||
| 620 | typedef enum { | ||
| 621 | GHOSTTY_GOTO_TAB_PREVIOUS = -1, | ||
| 622 | GHOSTTY_GOTO_TAB_NEXT = -2, | ||
| 623 | GHOSTTY_GOTO_TAB_LAST = -3, | ||
| 624 | } ghostty_action_goto_tab_e; | ||
| 625 | |||
| 626 | // apprt.action.Fullscreen | ||
| 627 | typedef enum { | ||
| 628 | GHOSTTY_FULLSCREEN_NATIVE, | ||
| 629 | GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE, | ||
| 630 | GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU, | ||
| 631 | GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH, | ||
| 632 | } ghostty_action_fullscreen_e; | ||
| 633 | |||
| 634 | // apprt.action.FloatWindow | ||
| 635 | typedef enum { | ||
| 636 | GHOSTTY_FLOAT_WINDOW_ON, | ||
| 637 | GHOSTTY_FLOAT_WINDOW_OFF, | ||
| 638 | GHOSTTY_FLOAT_WINDOW_TOGGLE, | ||
| 639 | } ghostty_action_float_window_e; | ||
| 640 | |||
| 641 | // apprt.action.SecureInput | ||
| 642 | typedef enum { | ||
| 643 | GHOSTTY_SECURE_INPUT_ON, | ||
| 644 | GHOSTTY_SECURE_INPUT_OFF, | ||
| 645 | GHOSTTY_SECURE_INPUT_TOGGLE, | ||
| 646 | } ghostty_action_secure_input_e; | ||
| 647 | |||
| 648 | // apprt.action.Inspector | ||
| 649 | typedef enum { | ||
| 650 | GHOSTTY_INSPECTOR_TOGGLE, | ||
| 651 | GHOSTTY_INSPECTOR_SHOW, | ||
| 652 | GHOSTTY_INSPECTOR_HIDE, | ||
| 653 | } ghostty_action_inspector_e; | ||
| 654 | |||
| 655 | // apprt.action.QuitTimer | ||
| 656 | typedef enum { | ||
| 657 | GHOSTTY_QUIT_TIMER_START, | ||
| 658 | GHOSTTY_QUIT_TIMER_STOP, | ||
| 659 | } ghostty_action_quit_timer_e; | ||
| 660 | |||
| 661 | // apprt.action.Readonly | ||
| 662 | typedef enum { | ||
| 663 | GHOSTTY_READONLY_OFF, | ||
| 664 | GHOSTTY_READONLY_ON, | ||
| 665 | } ghostty_action_readonly_e; | ||
| 666 | |||
| 667 | // apprt.action.DesktopNotification.C | ||
| 668 | typedef struct { | ||
| 669 | const char* title; | ||
| 670 | const char* body; | ||
| 671 | } ghostty_action_desktop_notification_s; | ||
| 672 | |||
| 673 | // apprt.action.SetTitle.C | ||
| 674 | typedef struct { | ||
| 675 | const char* title; | ||
| 676 | } ghostty_action_set_title_s; | ||
| 677 | |||
| 678 | // apprt.action.PromptTitle | ||
| 679 | typedef enum { | ||
| 680 | GHOSTTY_PROMPT_TITLE_SURFACE, | ||
| 681 | GHOSTTY_PROMPT_TITLE_TAB, | ||
| 682 | } ghostty_action_prompt_title_e; | ||
| 683 | |||
| 684 | // apprt.action.Pwd.C | ||
| 685 | typedef struct { | ||
| 686 | const char* pwd; | ||
| 687 | } ghostty_action_pwd_s; | ||
| 688 | |||
| 689 | // terminal.MouseShape | ||
| 690 | typedef enum { | ||
| 691 | GHOSTTY_MOUSE_SHAPE_DEFAULT, | ||
| 692 | GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU, | ||
| 693 | GHOSTTY_MOUSE_SHAPE_HELP, | ||
| 694 | GHOSTTY_MOUSE_SHAPE_POINTER, | ||
| 695 | GHOSTTY_MOUSE_SHAPE_PROGRESS, | ||
| 696 | GHOSTTY_MOUSE_SHAPE_WAIT, | ||
| 697 | GHOSTTY_MOUSE_SHAPE_CELL, | ||
| 698 | GHOSTTY_MOUSE_SHAPE_CROSSHAIR, | ||
| 699 | GHOSTTY_MOUSE_SHAPE_TEXT, | ||
| 700 | GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT, | ||
| 701 | GHOSTTY_MOUSE_SHAPE_ALIAS, | ||
| 702 | GHOSTTY_MOUSE_SHAPE_COPY, | ||
| 703 | GHOSTTY_MOUSE_SHAPE_MOVE, | ||
| 704 | GHOSTTY_MOUSE_SHAPE_NO_DROP, | ||
| 705 | GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED, | ||
| 706 | GHOSTTY_MOUSE_SHAPE_GRAB, | ||
| 707 | GHOSTTY_MOUSE_SHAPE_GRABBING, | ||
| 708 | GHOSTTY_MOUSE_SHAPE_ALL_SCROLL, | ||
| 709 | GHOSTTY_MOUSE_SHAPE_COL_RESIZE, | ||
| 710 | GHOSTTY_MOUSE_SHAPE_ROW_RESIZE, | ||
| 711 | GHOSTTY_MOUSE_SHAPE_N_RESIZE, | ||
| 712 | GHOSTTY_MOUSE_SHAPE_E_RESIZE, | ||
| 713 | GHOSTTY_MOUSE_SHAPE_S_RESIZE, | ||
| 714 | GHOSTTY_MOUSE_SHAPE_W_RESIZE, | ||
| 715 | GHOSTTY_MOUSE_SHAPE_NE_RESIZE, | ||
| 716 | GHOSTTY_MOUSE_SHAPE_NW_RESIZE, | ||
| 717 | GHOSTTY_MOUSE_SHAPE_SE_RESIZE, | ||
| 718 | GHOSTTY_MOUSE_SHAPE_SW_RESIZE, | ||
| 719 | GHOSTTY_MOUSE_SHAPE_EW_RESIZE, | ||
| 720 | GHOSTTY_MOUSE_SHAPE_NS_RESIZE, | ||
| 721 | GHOSTTY_MOUSE_SHAPE_NESW_RESIZE, | ||
| 722 | GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE, | ||
| 723 | GHOSTTY_MOUSE_SHAPE_ZOOM_IN, | ||
| 724 | GHOSTTY_MOUSE_SHAPE_ZOOM_OUT, | ||
| 725 | } ghostty_action_mouse_shape_e; | ||
| 726 | |||
| 727 | // apprt.action.MouseVisibility | ||
| 728 | typedef enum { | ||
| 729 | GHOSTTY_MOUSE_VISIBLE, | ||
| 730 | GHOSTTY_MOUSE_HIDDEN, | ||
| 731 | } ghostty_action_mouse_visibility_e; | ||
| 732 | |||
| 733 | // apprt.action.MouseOverLink | ||
| 734 | typedef struct { | ||
| 735 | const char* url; | ||
| 736 | size_t len; | ||
| 737 | } ghostty_action_mouse_over_link_s; | ||
| 738 | |||
| 739 | // apprt.action.SizeLimit | ||
| 740 | typedef struct { | ||
| 741 | uint32_t min_width; | ||
| 742 | uint32_t min_height; | ||
| 743 | uint32_t max_width; | ||
| 744 | uint32_t max_height; | ||
| 745 | } ghostty_action_size_limit_s; | ||
| 746 | |||
| 747 | // apprt.action.InitialSize | ||
| 748 | typedef struct { | ||
| 749 | uint32_t width; | ||
| 750 | uint32_t height; | ||
| 751 | } ghostty_action_initial_size_s; | ||
| 752 | |||
| 753 | // apprt.action.CellSize | ||
| 754 | typedef struct { | ||
| 755 | uint32_t width; | ||
| 756 | uint32_t height; | ||
| 757 | } ghostty_action_cell_size_s; | ||
| 758 | |||
| 759 | // renderer.Health | ||
| 760 | typedef enum { | ||
| 761 | GHOSTTY_RENDERER_HEALTH_HEALTHY, | ||
| 762 | GHOSTTY_RENDERER_HEALTH_UNHEALTHY, | ||
| 763 | } ghostty_action_renderer_health_e; | ||
| 764 | |||
| 765 | // apprt.action.KeySequence | ||
| 766 | typedef struct { | ||
| 767 | bool active; | ||
| 768 | ghostty_input_trigger_s trigger; | ||
| 769 | } ghostty_action_key_sequence_s; | ||
| 770 | |||
| 771 | // apprt.action.KeyTable.Tag | ||
| 772 | typedef enum { | ||
| 773 | GHOSTTY_KEY_TABLE_ACTIVATE, | ||
| 774 | GHOSTTY_KEY_TABLE_DEACTIVATE, | ||
| 775 | GHOSTTY_KEY_TABLE_DEACTIVATE_ALL, | ||
| 776 | } ghostty_action_key_table_tag_e; | ||
| 777 | |||
| 778 | // apprt.action.KeyTable.CValue | ||
| 779 | typedef union { | ||
| 780 | struct { | ||
| 781 | const char *name; | ||
| 782 | size_t len; | ||
| 783 | } activate; | ||
| 784 | } ghostty_action_key_table_u; | ||
| 785 | |||
| 786 | // apprt.action.KeyTable.C | ||
| 787 | typedef struct { | ||
| 788 | ghostty_action_key_table_tag_e tag; | ||
| 789 | ghostty_action_key_table_u value; | ||
| 790 | } ghostty_action_key_table_s; | ||
| 791 | |||
| 792 | // apprt.action.ColorKind | ||
| 793 | typedef enum { | ||
| 794 | GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1, | ||
| 795 | GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2, | ||
| 796 | GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3, | ||
| 797 | } ghostty_action_color_kind_e; | ||
| 798 | |||
| 799 | // apprt.action.ColorChange | ||
| 800 | typedef struct { | ||
| 801 | ghostty_action_color_kind_e kind; | ||
| 802 | uint8_t r; | ||
| 803 | uint8_t g; | ||
| 804 | uint8_t b; | ||
| 805 | } ghostty_action_color_change_s; | ||
| 806 | |||
| 807 | // apprt.action.ConfigChange | ||
| 808 | typedef struct { | ||
| 809 | ghostty_config_t config; | ||
| 810 | } ghostty_action_config_change_s; | ||
| 811 | |||
| 812 | // apprt.action.ReloadConfig | ||
| 813 | typedef struct { | ||
| 814 | bool soft; | ||
| 815 | } ghostty_action_reload_config_s; | ||
| 816 | |||
| 817 | // apprt.action.OpenUrlKind | ||
| 818 | typedef enum { | ||
| 819 | GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, | ||
| 820 | GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, | ||
| 821 | GHOSTTY_ACTION_OPEN_URL_KIND_HTML, | ||
| 822 | } ghostty_action_open_url_kind_e; | ||
| 823 | |||
| 824 | // apprt.action.OpenUrl.C | ||
| 825 | typedef struct { | ||
| 826 | ghostty_action_open_url_kind_e kind; | ||
| 827 | const char* url; | ||
| 828 | uintptr_t len; | ||
| 829 | } ghostty_action_open_url_s; | ||
| 830 | |||
| 831 | // apprt.action.CloseTabMode | ||
| 832 | typedef enum { | ||
| 833 | GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, | ||
| 834 | GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, | ||
| 835 | GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, | ||
| 836 | } ghostty_action_close_tab_mode_e; | ||
| 837 | |||
| 838 | // apprt.surface.Message.ChildExited | ||
| 839 | typedef struct { | ||
| 840 | uint32_t exit_code; | ||
| 841 | uint64_t timetime_ms; | ||
| 842 | } ghostty_surface_message_childexited_s; | ||
| 843 | |||
| 844 | // terminal.osc.Command.ProgressReport.State | ||
| 845 | typedef enum { | ||
| 846 | GHOSTTY_PROGRESS_STATE_REMOVE, | ||
| 847 | GHOSTTY_PROGRESS_STATE_SET, | ||
| 848 | GHOSTTY_PROGRESS_STATE_ERROR, | ||
| 849 | GHOSTTY_PROGRESS_STATE_INDETERMINATE, | ||
| 850 | GHOSTTY_PROGRESS_STATE_PAUSE, | ||
| 851 | } ghostty_action_progress_report_state_e; | ||
| 852 | |||
| 853 | // terminal.osc.Command.ProgressReport.C | ||
| 854 | typedef struct { | ||
| 855 | ghostty_action_progress_report_state_e state; | ||
| 856 | // -1 if no progress was reported, otherwise 0-100 indicating percent | ||
| 857 | // completeness. | ||
| 858 | int8_t progress; | ||
| 859 | } ghostty_action_progress_report_s; | ||
| 860 | |||
| 861 | // apprt.action.CommandFinished.C | ||
| 862 | typedef struct { | ||
| 863 | // -1 if no exit code was reported, otherwise 0-255 | ||
| 864 | int16_t exit_code; | ||
| 865 | // number of nanoseconds that command was running for | ||
| 866 | uint64_t duration; | ||
| 867 | } ghostty_action_command_finished_s; | ||
| 868 | |||
| 869 | // apprt.action.StartSearch.C | ||
| 870 | typedef struct { | ||
| 871 | const char* needle; | ||
| 872 | } ghostty_action_start_search_s; | ||
| 873 | |||
| 874 | // apprt.action.SearchTotal | ||
| 875 | typedef struct { | ||
| 876 | ssize_t total; | ||
| 877 | } ghostty_action_search_total_s; | ||
| 878 | |||
| 879 | // apprt.action.SearchSelected | ||
| 880 | typedef struct { | ||
| 881 | ssize_t selected; | ||
| 882 | } ghostty_action_search_selected_s; | ||
| 883 | |||
| 884 | // terminal.Scrollbar | ||
| 885 | typedef struct { | ||
| 886 | uint64_t total; | ||
| 887 | uint64_t offset; | ||
| 888 | uint64_t len; | ||
| 889 | } ghostty_action_scrollbar_s; | ||
| 890 | |||
| 891 | // apprt.Action.Key | ||
| 892 | typedef enum { | ||
| 893 | GHOSTTY_ACTION_QUIT, | ||
| 894 | GHOSTTY_ACTION_NEW_WINDOW, | ||
| 895 | GHOSTTY_ACTION_NEW_TAB, | ||
| 896 | GHOSTTY_ACTION_CLOSE_TAB, | ||
| 897 | GHOSTTY_ACTION_NEW_SPLIT, | ||
| 898 | GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, | ||
| 899 | GHOSTTY_ACTION_TOGGLE_MAXIMIZE, | ||
| 900 | GHOSTTY_ACTION_TOGGLE_FULLSCREEN, | ||
| 901 | GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, | ||
| 902 | GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, | ||
| 903 | GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, | ||
| 904 | GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, | ||
| 905 | GHOSTTY_ACTION_TOGGLE_VISIBILITY, | ||
| 906 | GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY, | ||
| 907 | GHOSTTY_ACTION_MOVE_TAB, | ||
| 908 | GHOSTTY_ACTION_GOTO_TAB, | ||
| 909 | GHOSTTY_ACTION_GOTO_SPLIT, | ||
| 910 | GHOSTTY_ACTION_GOTO_WINDOW, | ||
| 911 | GHOSTTY_ACTION_RESIZE_SPLIT, | ||
| 912 | GHOSTTY_ACTION_EQUALIZE_SPLITS, | ||
| 913 | GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, | ||
| 914 | GHOSTTY_ACTION_PRESENT_TERMINAL, | ||
| 915 | GHOSTTY_ACTION_SIZE_LIMIT, | ||
| 916 | GHOSTTY_ACTION_RESET_WINDOW_SIZE, | ||
| 917 | GHOSTTY_ACTION_INITIAL_SIZE, | ||
| 918 | GHOSTTY_ACTION_CELL_SIZE, | ||
| 919 | GHOSTTY_ACTION_SCROLLBAR, | ||
| 920 | GHOSTTY_ACTION_RENDER, | ||
| 921 | GHOSTTY_ACTION_INSPECTOR, | ||
| 922 | GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, | ||
| 923 | GHOSTTY_ACTION_RENDER_INSPECTOR, | ||
| 924 | GHOSTTY_ACTION_DESKTOP_NOTIFICATION, | ||
| 925 | GHOSTTY_ACTION_SET_TITLE, | ||
| 926 | GHOSTTY_ACTION_SET_TAB_TITLE, | ||
| 927 | GHOSTTY_ACTION_PROMPT_TITLE, | ||
| 928 | GHOSTTY_ACTION_PWD, | ||
| 929 | GHOSTTY_ACTION_MOUSE_SHAPE, | ||
| 930 | GHOSTTY_ACTION_MOUSE_VISIBILITY, | ||
| 931 | GHOSTTY_ACTION_MOUSE_OVER_LINK, | ||
| 932 | GHOSTTY_ACTION_RENDERER_HEALTH, | ||
| 933 | GHOSTTY_ACTION_OPEN_CONFIG, | ||
| 934 | GHOSTTY_ACTION_QUIT_TIMER, | ||
| 935 | GHOSTTY_ACTION_FLOAT_WINDOW, | ||
| 936 | GHOSTTY_ACTION_SECURE_INPUT, | ||
| 937 | GHOSTTY_ACTION_KEY_SEQUENCE, | ||
| 938 | GHOSTTY_ACTION_KEY_TABLE, | ||
| 939 | GHOSTTY_ACTION_COLOR_CHANGE, | ||
| 940 | GHOSTTY_ACTION_RELOAD_CONFIG, | ||
| 941 | GHOSTTY_ACTION_CONFIG_CHANGE, | ||
| 942 | GHOSTTY_ACTION_CLOSE_WINDOW, | ||
| 943 | GHOSTTY_ACTION_RING_BELL, | ||
| 944 | GHOSTTY_ACTION_UNDO, | ||
| 945 | GHOSTTY_ACTION_REDO, | ||
| 946 | GHOSTTY_ACTION_CHECK_FOR_UPDATES, | ||
| 947 | GHOSTTY_ACTION_OPEN_URL, | ||
| 948 | GHOSTTY_ACTION_SHOW_CHILD_EXITED, | ||
| 949 | GHOSTTY_ACTION_PROGRESS_REPORT, | ||
| 950 | GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, | ||
| 951 | GHOSTTY_ACTION_COMMAND_FINISHED, | ||
| 952 | GHOSTTY_ACTION_START_SEARCH, | ||
| 953 | GHOSTTY_ACTION_END_SEARCH, | ||
| 954 | GHOSTTY_ACTION_SEARCH_TOTAL, | ||
| 955 | GHOSTTY_ACTION_SEARCH_SELECTED, | ||
| 956 | GHOSTTY_ACTION_READONLY, | ||
| 957 | GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, | ||
| 958 | } ghostty_action_tag_e; | ||
| 959 | |||
| 960 | typedef union { | ||
| 961 | ghostty_action_split_direction_e new_split; | ||
| 962 | ghostty_action_fullscreen_e toggle_fullscreen; | ||
| 963 | ghostty_action_move_tab_s move_tab; | ||
| 964 | ghostty_action_goto_tab_e goto_tab; | ||
| 965 | ghostty_action_goto_split_e goto_split; | ||
| 966 | ghostty_action_goto_window_e goto_window; | ||
| 967 | ghostty_action_resize_split_s resize_split; | ||
| 968 | ghostty_action_size_limit_s size_limit; | ||
| 969 | ghostty_action_initial_size_s initial_size; | ||
| 970 | ghostty_action_cell_size_s cell_size; | ||
| 971 | ghostty_action_scrollbar_s scrollbar; | ||
| 972 | ghostty_action_inspector_e inspector; | ||
| 973 | ghostty_action_desktop_notification_s desktop_notification; | ||
| 974 | ghostty_action_set_title_s set_title; | ||
| 975 | ghostty_action_set_title_s set_tab_title; | ||
| 976 | ghostty_action_prompt_title_e prompt_title; | ||
| 977 | ghostty_action_pwd_s pwd; | ||
| 978 | ghostty_action_mouse_shape_e mouse_shape; | ||
| 979 | ghostty_action_mouse_visibility_e mouse_visibility; | ||
| 980 | ghostty_action_mouse_over_link_s mouse_over_link; | ||
| 981 | ghostty_action_renderer_health_e renderer_health; | ||
| 982 | ghostty_action_quit_timer_e quit_timer; | ||
| 983 | ghostty_action_float_window_e float_window; | ||
| 984 | ghostty_action_secure_input_e secure_input; | ||
| 985 | ghostty_action_key_sequence_s key_sequence; | ||
| 986 | ghostty_action_key_table_s key_table; | ||
| 987 | ghostty_action_color_change_s color_change; | ||
| 988 | ghostty_action_reload_config_s reload_config; | ||
| 989 | ghostty_action_config_change_s config_change; | ||
| 990 | ghostty_action_open_url_s open_url; | ||
| 991 | ghostty_action_close_tab_mode_e close_tab_mode; | ||
| 992 | ghostty_surface_message_childexited_s child_exited; | ||
| 993 | ghostty_action_progress_report_s progress_report; | ||
| 994 | ghostty_action_command_finished_s command_finished; | ||
| 995 | ghostty_action_start_search_s start_search; | ||
| 996 | ghostty_action_search_total_s search_total; | ||
| 997 | ghostty_action_search_selected_s search_selected; | ||
| 998 | ghostty_action_readonly_e readonly; | ||
| 999 | } ghostty_action_u; | ||
| 1000 | |||
| 1001 | typedef struct { | ||
| 1002 | ghostty_action_tag_e tag; | ||
| 1003 | ghostty_action_u action; | ||
| 1004 | } ghostty_action_s; | ||
| 1005 | |||
| 1006 | typedef void (*ghostty_runtime_wakeup_cb)(void*); | ||
| 1007 | typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, | ||
| 1008 | ghostty_clipboard_e, | ||
| 1009 | void*); | ||
| 1010 | typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( | ||
| 1011 | void*, | ||
| 1012 | const char*, | ||
| 1013 | void*, | ||
| 1014 | ghostty_clipboard_request_e); | ||
| 1015 | typedef void (*ghostty_runtime_write_clipboard_cb)(void*, | ||
| 1016 | ghostty_clipboard_e, | ||
| 1017 | const ghostty_clipboard_content_s*, | ||
| 1018 | size_t, | ||
| 1019 | bool); | ||
| 1020 | typedef void (*ghostty_runtime_close_surface_cb)(void*, bool); | ||
| 1021 | typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t, | ||
| 1022 | ghostty_target_s, | ||
| 1023 | ghostty_action_s); | ||
| 1024 | |||
| 1025 | typedef struct { | ||
| 1026 | void* userdata; | ||
| 1027 | bool supports_selection_clipboard; | ||
| 1028 | ghostty_runtime_wakeup_cb wakeup_cb; | ||
| 1029 | ghostty_runtime_action_cb action_cb; | ||
| 1030 | ghostty_runtime_read_clipboard_cb read_clipboard_cb; | ||
| 1031 | ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb; | ||
| 1032 | ghostty_runtime_write_clipboard_cb write_clipboard_cb; | ||
| 1033 | ghostty_runtime_close_surface_cb close_surface_cb; | ||
| 1034 | } ghostty_runtime_config_s; | ||
| 1035 | |||
| 1036 | // apprt.ipc.Target.Key | ||
| 1037 | typedef enum { | ||
| 1038 | GHOSTTY_IPC_TARGET_CLASS, | ||
| 1039 | GHOSTTY_IPC_TARGET_DETECT, | ||
| 1040 | } ghostty_ipc_target_tag_e; | ||
| 1041 | |||
| 1042 | typedef union { | ||
| 1043 | char *klass; | ||
| 1044 | } ghostty_ipc_target_u; | ||
| 1045 | |||
| 1046 | typedef struct { | ||
| 1047 | ghostty_ipc_target_tag_e tag; | ||
| 1048 | ghostty_ipc_target_u target; | ||
| 1049 | } chostty_ipc_target_s; | ||
| 1050 | |||
| 1051 | // apprt.ipc.Action.NewWindow | ||
| 1052 | typedef struct { | ||
| 1053 | // This should be a null terminated list of strings. | ||
| 1054 | const char **arguments; | ||
| 1055 | } ghostty_ipc_action_new_window_s; | ||
| 1056 | |||
| 1057 | typedef union { | ||
| 1058 | ghostty_ipc_action_new_window_s new_window; | ||
| 1059 | } ghostty_ipc_action_u; | ||
| 1060 | |||
| 1061 | // apprt.ipc.Action.Key | ||
| 1062 | typedef enum { | ||
| 1063 | GHOSTTY_IPC_ACTION_NEW_WINDOW, | ||
| 1064 | } ghostty_ipc_action_tag_e; | ||
| 1065 | |||
| 1066 | //------------------------------------------------------------------- | ||
| 1067 | // Published API | ||
| 1068 | |||
| 1069 | GHOSTTY_API int ghostty_init(uintptr_t, char**); | ||
| 1070 | GHOSTTY_API void ghostty_cli_try_action(void); | ||
| 1071 | GHOSTTY_API ghostty_info_s ghostty_info(void); | ||
| 1072 | GHOSTTY_API const char* ghostty_translate(const char*); | ||
| 1073 | GHOSTTY_API void ghostty_string_free(ghostty_string_s); | ||
| 1074 | |||
| 1075 | GHOSTTY_API ghostty_config_t ghostty_config_new(); | ||
| 1076 | GHOSTTY_API void ghostty_config_free(ghostty_config_t); | ||
| 1077 | GHOSTTY_API ghostty_config_t ghostty_config_clone(ghostty_config_t); | ||
| 1078 | GHOSTTY_API void ghostty_config_load_cli_args(ghostty_config_t); | ||
| 1079 | GHOSTTY_API void ghostty_config_load_file(ghostty_config_t, const char*); | ||
| 1080 | GHOSTTY_API void ghostty_config_load_default_files(ghostty_config_t); | ||
| 1081 | GHOSTTY_API void ghostty_config_load_recursive_files(ghostty_config_t); | ||
| 1082 | GHOSTTY_API void ghostty_config_finalize(ghostty_config_t); | ||
| 1083 | GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t); | ||
| 1084 | GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, | ||
| 1085 | const char*, | ||
| 1086 | uintptr_t); | ||
| 1087 | GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t); | ||
| 1088 | GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); | ||
| 1089 | GHOSTTY_API ghostty_string_s ghostty_config_open_path(void); | ||
| 1090 | |||
| 1091 | GHOSTTY_API ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, | ||
| 1092 | ghostty_config_t); | ||
| 1093 | GHOSTTY_API void ghostty_app_free(ghostty_app_t); | ||
| 1094 | GHOSTTY_API void ghostty_app_tick(ghostty_app_t); | ||
| 1095 | GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t); | ||
| 1096 | GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool); | ||
| 1097 | GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); | ||
| 1098 | GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); | ||
| 1099 | GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t); | ||
| 1100 | GHOSTTY_API void ghostty_app_open_config(ghostty_app_t); | ||
| 1101 | GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); | ||
| 1102 | GHOSTTY_API bool ghostty_app_needs_confirm_quit(ghostty_app_t); | ||
| 1103 | GHOSTTY_API bool ghostty_app_has_global_keybinds(ghostty_app_t); | ||
| 1104 | GHOSTTY_API void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e); | ||
| 1105 | |||
| 1106 | GHOSTTY_API ghostty_surface_config_s ghostty_surface_config_new(); | ||
| 1107 | |||
| 1108 | GHOSTTY_API ghostty_surface_t ghostty_surface_new(ghostty_app_t, | ||
| 1109 | const ghostty_surface_config_s*); | ||
| 1110 | GHOSTTY_API void ghostty_surface_free(ghostty_surface_t); | ||
| 1111 | GHOSTTY_API void* ghostty_surface_userdata(ghostty_surface_t); | ||
| 1112 | GHOSTTY_API ghostty_app_t ghostty_surface_app(ghostty_surface_t); | ||
| 1113 | GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e); | ||
| 1114 | GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t); | ||
| 1115 | GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); | ||
| 1116 | GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t); | ||
| 1117 | GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t); | ||
| 1118 | GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t); | ||
| 1119 | GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, const uint8_t*, size_t); | ||
| 1120 | GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t, | ||
| 1121 | ghostty_surface_write_fn, | ||
| 1122 | void*); | ||
| 1123 | GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); | ||
| 1124 | GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool); | ||
| 1125 | GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool); | ||
| 1126 | GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); | ||
| 1127 | GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); | ||
| 1128 | GHOSTTY_API uint64_t ghostty_surface_foreground_pid(ghostty_surface_t); | ||
| 1129 | GHOSTTY_API ghostty_string_s ghostty_surface_tty_name(ghostty_surface_t); | ||
| 1130 | GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t, | ||
| 1131 | ghostty_color_scheme_e); | ||
| 1132 | GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, | ||
| 1133 | ghostty_input_mods_e); | ||
| 1134 | GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); | ||
| 1135 | GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t, | ||
| 1136 | ghostty_input_key_s, | ||
| 1137 | ghostty_binding_flags_e*); | ||
| 1138 | GHOSTTY_API void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); | ||
| 1139 | GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); | ||
| 1140 | GHOSTTY_API bool ghostty_surface_mouse_captured(ghostty_surface_t); | ||
| 1141 | GHOSTTY_API bool ghostty_surface_mouse_button(ghostty_surface_t, | ||
| 1142 | ghostty_input_mouse_state_e, | ||
| 1143 | ghostty_input_mouse_button_e, | ||
| 1144 | ghostty_input_mods_e); | ||
| 1145 | GHOSTTY_API void ghostty_surface_mouse_pos(ghostty_surface_t, | ||
| 1146 | double, | ||
| 1147 | double, | ||
| 1148 | ghostty_input_mods_e); | ||
| 1149 | GHOSTTY_API void ghostty_surface_mouse_scroll(ghostty_surface_t, | ||
| 1150 | double, | ||
| 1151 | double, | ||
| 1152 | ghostty_input_scroll_mods_t); | ||
| 1153 | GHOSTTY_API void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); | ||
| 1154 | GHOSTTY_API void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*); | ||
| 1155 | GHOSTTY_API void ghostty_surface_request_close(ghostty_surface_t); | ||
| 1156 | GHOSTTY_API void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); | ||
| 1157 | GHOSTTY_API void ghostty_surface_split_focus(ghostty_surface_t, | ||
| 1158 | ghostty_action_goto_split_e); | ||
| 1159 | GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, | ||
| 1160 | ghostty_action_resize_split_direction_e, | ||
| 1161 | uint16_t); | ||
| 1162 | GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); | ||
| 1163 | GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); | ||
| 1164 | GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, | ||
| 1165 | const char*, | ||
| 1166 | void*, | ||
| 1167 | bool); | ||
| 1168 | GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); | ||
| 1169 | GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); | ||
| 1170 | GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, | ||
| 1171 | ghostty_selection_s, | ||
| 1172 | ghostty_text_s*); | ||
| 1173 | GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*); | ||
| 1174 | |||
| 1175 | #ifdef __APPLE__ | ||
| 1176 | GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); | ||
| 1177 | GHOSTTY_API void* ghostty_surface_quicklook_font(ghostty_surface_t); | ||
| 1178 | GHOSTTY_API bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*); | ||
| 1179 | #endif | ||
| 1180 | |||
| 1181 | GHOSTTY_API ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); | ||
| 1182 | GHOSTTY_API void ghostty_inspector_free(ghostty_surface_t); | ||
| 1183 | GHOSTTY_API void ghostty_inspector_set_focus(ghostty_inspector_t, bool); | ||
| 1184 | GHOSTTY_API void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double); | ||
| 1185 | GHOSTTY_API void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t); | ||
| 1186 | GHOSTTY_API void ghostty_inspector_mouse_button(ghostty_inspector_t, | ||
| 1187 | ghostty_input_mouse_state_e, | ||
| 1188 | ghostty_input_mouse_button_e, | ||
| 1189 | ghostty_input_mods_e); | ||
| 1190 | GHOSTTY_API void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double); | ||
| 1191 | GHOSTTY_API void ghostty_inspector_mouse_scroll(ghostty_inspector_t, | ||
| 1192 | double, | ||
| 1193 | double, | ||
| 1194 | ghostty_input_scroll_mods_t); | ||
| 1195 | GHOSTTY_API void ghostty_inspector_key(ghostty_inspector_t, | ||
| 1196 | ghostty_input_action_e, | ||
| 1197 | ghostty_input_key_e, | ||
| 1198 | ghostty_input_mods_e); | ||
| 1199 | GHOSTTY_API void ghostty_inspector_text(ghostty_inspector_t, const char*); | ||
| 1200 | |||
| 1201 | #ifdef __APPLE__ | ||
| 1202 | GHOSTTY_API bool ghostty_inspector_metal_init(ghostty_inspector_t, void*); | ||
| 1203 | GHOSTTY_API void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*); | ||
| 1204 | GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); | ||
| 1205 | #endif | ||
| 1206 | |||
| 1207 | // APIs I'd like to get rid of eventually but are still needed for now. | ||
| 1208 | // Don't use these unless you know what you're doing. | ||
| 1209 | GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*); | ||
| 1210 | |||
| 1211 | // Benchmark API, if available. | ||
| 1212 | GHOSTTY_API bool ghostty_benchmark_cli(const char*, const char*); | ||
| 1213 | |||
| 1214 | #ifdef __cplusplus | ||
| 1215 | } | ||
| 1216 | #endif | ||
| 1217 | |||
| 1218 | #endif /* GHOSTTY_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h created+87| ... | @@ -0,0 +1,87 @@ | ||
| 1 | /** | ||
| 2 | * @file vt.h | ||
| 3 | * | ||
| 4 | * libghostty-vt - Virtual terminal emulator library | ||
| 5 | * | ||
| 6 | * This library provides functionality for parsing and handling terminal | ||
| 7 | * escape sequences as well as maintaining terminal state such as styles, | ||
| 8 | * cursor position, screen, scrollback, and more. | ||
| 9 | * | ||
| 10 | * WARNING: This is an incomplete, work-in-progress API. It is not yet | ||
| 11 | * stable and is definitely going to change. | ||
| 12 | */ | ||
| 13 | |||
| 14 | /** | ||
| 15 | * @mainpage libghostty-vt - Virtual Terminal Emulator Library | ||
| 16 | * | ||
| 17 | * libghostty-vt is a C library which implements a modern terminal emulator, | ||
| 18 | * extracted from the [Ghostty](https://ghostty.org) terminal emulator. | ||
| 19 | * | ||
| 20 | * libghostty-vt contains the logic for handling the core parts of a terminal | ||
| 21 | * emulator: parsing terminal escape sequences, maintaining terminal state, | ||
| 22 | * encoding input events, etc. It can handle scrollback, line wrapping, | ||
| 23 | * reflow on resize, and more. | ||
| 24 | * | ||
| 25 | * @warning This library is currently in development and the API is not yet stable. | ||
| 26 | * Breaking changes are expected in future versions. Use with caution in production code. | ||
| 27 | * | ||
| 28 | * @section groups_sec API Reference | ||
| 29 | * | ||
| 30 | * The API is organized into the following groups: | ||
| 31 | * - @ref key "Key Encoding" - Encode key events into terminal sequences | ||
| 32 | * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences | ||
| 33 | * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences | ||
| 34 | * - @ref paste "Paste Utilities" - Validate paste data safety | ||
| 35 | * - @ref allocator "Memory Management" - Memory management and custom allocators | ||
| 36 | * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions | ||
| 37 | * | ||
| 38 | * @section examples_sec Examples | ||
| 39 | * | ||
| 40 | * Complete working examples: | ||
| 41 | * - @ref c-vt/src/main.c - OSC parser example | ||
| 42 | * - @ref c-vt-key-encode/src/main.c - Key encoding example | ||
| 43 | * - @ref c-vt-paste/src/main.c - Paste safety check example | ||
| 44 | * - @ref c-vt-sgr/src/main.c - SGR parser example | ||
| 45 | * | ||
| 46 | */ | ||
| 47 | |||
| 48 | /** @example c-vt/src/main.c | ||
| 49 | * This example demonstrates how to use the OSC parser to parse an OSC sequence, | ||
| 50 | * extract command information, and retrieve command-specific data like window titles. | ||
| 51 | */ | ||
| 52 | |||
| 53 | /** @example c-vt-key-encode/src/main.c | ||
| 54 | * This example demonstrates how to use the key encoder to convert key events | ||
| 55 | * into terminal escape sequences using the Kitty keyboard protocol. | ||
| 56 | */ | ||
| 57 | |||
| 58 | /** @example c-vt-paste/src/main.c | ||
| 59 | * This example demonstrates how to use the paste utilities to check if | ||
| 60 | * paste data is safe before sending it to the terminal. | ||
| 61 | */ | ||
| 62 | |||
| 63 | /** @example c-vt-sgr/src/main.c | ||
| 64 | * This example demonstrates how to use the SGR parser to parse terminal | ||
| 65 | * styling sequences and extract text attributes like colors and underline styles. | ||
| 66 | */ | ||
| 67 | |||
| 68 | #ifndef GHOSTTY_VT_H | ||
| 69 | #define GHOSTTY_VT_H | ||
| 70 | |||
| 71 | #ifdef __cplusplus | ||
| 72 | extern "C" { | ||
| 73 | #endif | ||
| 74 | |||
| 75 | #include <ghostty/vt/result.h> | ||
| 76 | #include <ghostty/vt/allocator.h> | ||
| 77 | #include <ghostty/vt/osc.h> | ||
| 78 | #include <ghostty/vt/sgr.h> | ||
| 79 | #include <ghostty/vt/key.h> | ||
| 80 | #include <ghostty/vt/paste.h> | ||
| 81 | #include <ghostty/vt/wasm.h> | ||
| 82 | |||
| 83 | #ifdef __cplusplus | ||
| 84 | } | ||
| 85 | #endif | ||
| 86 | |||
| 87 | #endif /* GHOSTTY_VT_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h created+196| ... | @@ -0,0 +1,196 @@ | ||
| 1 | /** | ||
| 2 | * @file allocator.h | ||
| 3 | * | ||
| 4 | * Memory management interface for libghostty-vt. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_ALLOCATOR_H | ||
| 8 | #define GHOSTTY_VT_ALLOCATOR_H | ||
| 9 | |||
| 10 | #include <stdbool.h> | ||
| 11 | #include <stddef.h> | ||
| 12 | #include <stdint.h> | ||
| 13 | |||
| 14 | /** @defgroup allocator Memory Management | ||
| 15 | * | ||
| 16 | * libghostty-vt does require memory allocation for various operations, | ||
| 17 | * but is resilient to allocation failures and will gracefully handle | ||
| 18 | * out-of-memory situations by returning error codes. | ||
| 19 | * | ||
| 20 | * The exact memory management semantics are documented in the relevant | ||
| 21 | * functions and data structures. | ||
| 22 | * | ||
| 23 | * libghostty-vt uses explicit memory allocation via an allocator | ||
| 24 | * interface provided by GhosttyAllocator. The interface is based on the | ||
| 25 | * [Zig](https://ziglang.org) allocator interface, since this has been | ||
| 26 | * shown to be a flexible and powerful interface in practice and enables | ||
| 27 | * a wide variety of allocation strategies. | ||
| 28 | * | ||
| 29 | * **For the common case, you can pass NULL as the allocator for any | ||
| 30 | * function that accepts one,** and libghostty will use a default allocator. | ||
| 31 | * The default allocator will be libc malloc/free if libc is linked. | ||
| 32 | * Otherwise, a custom allocator is used (currently Zig's SMP allocator) | ||
| 33 | * that doesn't require any external dependencies. | ||
| 34 | * | ||
| 35 | * ## Basic Usage | ||
| 36 | * | ||
| 37 | * For simple use cases, you can ignore this interface entirely by passing NULL | ||
| 38 | * as the allocator parameter to functions that accept one. This will use the | ||
| 39 | * default allocator (typically libc malloc/free, if libc is linked, but | ||
| 40 | * we provide our own default allocator if libc isn't linked). | ||
| 41 | * | ||
| 42 | * To use a custom allocator: | ||
| 43 | * 1. Implement the GhosttyAllocatorVtable function pointers | ||
| 44 | * 2. Create a GhosttyAllocator struct with your vtable and context | ||
| 45 | * 3. Pass the allocator to functions that accept one | ||
| 46 | * | ||
| 47 | * @{ | ||
| 48 | */ | ||
| 49 | |||
| 50 | /** | ||
| 51 | * Function table for custom memory allocator operations. | ||
| 52 | * | ||
| 53 | * This vtable defines the interface for a custom memory allocator. All | ||
| 54 | * function pointers must be valid and non-NULL. | ||
| 55 | * | ||
| 56 | * @ingroup allocator | ||
| 57 | * | ||
| 58 | * If you're not going to use a custom allocator, you can ignore all of | ||
| 59 | * this. All functions that take an allocator pointer allow NULL to use a | ||
| 60 | * default allocator. | ||
| 61 | * | ||
| 62 | * The interface is based on the Zig allocator interface. I'll say up front | ||
| 63 | * that it is easy to look at this interface and think "wow, this is really | ||
| 64 | * overcomplicated". The reason for this complexity is well thought out by | ||
| 65 | * the Zig folks, and it enables a diverse set of allocation strategies | ||
| 66 | * as shown by the Zig ecosystem. As a consolation, please note that many | ||
| 67 | * of the arguments are only needed for advanced use cases and can be | ||
| 68 | * safely ignored in simple implementations. For example, if you look at | ||
| 69 | * the Zig implementation of the libc allocator in `lib/std/heap.zig` | ||
| 70 | * (search for CAllocator), you'll see it is very simple. | ||
| 71 | * | ||
| 72 | * We chose to align with the Zig allocator interface because: | ||
| 73 | * | ||
| 74 | * 1. It is a proven interface that serves a wide variety of use cases | ||
| 75 | * in the real world via the Zig ecosystem. It's shown to work. | ||
| 76 | * | ||
| 77 | * 2. Our core implementation itself is Zig, and this lets us very | ||
| 78 | * cheaply and easily convert between C and Zig allocators. | ||
| 79 | * | ||
| 80 | * NOTE(mitchellh): In the future, we can have default implementations of | ||
| 81 | * resize/remap and allow those to be null. | ||
| 82 | */ | ||
| 83 | typedef struct { | ||
| 84 | /** | ||
| 85 | * Return a pointer to `len` bytes with specified `alignment`, or return | ||
| 86 | * `NULL` indicating the allocation failed. | ||
| 87 | * | ||
| 88 | * @param ctx The allocator context | ||
| 89 | * @param len Number of bytes to allocate | ||
| 90 | * @param alignment Required alignment for the allocation. Guaranteed to | ||
| 91 | * be a power of two between 1 and 16 inclusive. | ||
| 92 | * @param ret_addr First return address of the allocation call stack (0 if not provided) | ||
| 93 | * @return Pointer to allocated memory, or NULL if allocation failed | ||
| 94 | */ | ||
| 95 | void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); | ||
| 96 | |||
| 97 | /** | ||
| 98 | * Attempt to expand or shrink memory in place. | ||
| 99 | * | ||
| 100 | * `memory_len` must equal the length requested from the most recent | ||
| 101 | * successful call to `alloc`, `resize`, or `remap`. `alignment` must | ||
| 102 | * equal the same value that was passed as the `alignment` parameter to | ||
| 103 | * the original `alloc` call. | ||
| 104 | * | ||
| 105 | * `new_len` must be greater than zero. | ||
| 106 | * | ||
| 107 | * @param ctx The allocator context | ||
| 108 | * @param memory Pointer to the memory block to resize | ||
| 109 | * @param memory_len Current size of the memory block | ||
| 110 | * @param alignment Alignment (must match original allocation) | ||
| 111 | * @param new_len New requested size | ||
| 112 | * @param ret_addr First return address of the allocation call stack (0 if not provided) | ||
| 113 | * @return true if resize was successful in-place, false if relocation would be required | ||
| 114 | */ | ||
| 115 | bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); | ||
| 116 | |||
| 117 | /** | ||
| 118 | * Attempt to expand or shrink memory, allowing relocation. | ||
| 119 | * | ||
| 120 | * `memory_len` must equal the length requested from the most recent | ||
| 121 | * successful call to `alloc`, `resize`, or `remap`. `alignment` must | ||
| 122 | * equal the same value that was passed as the `alignment` parameter to | ||
| 123 | * the original `alloc` call. | ||
| 124 | * | ||
| 125 | * A non-`NULL` return value indicates the resize was successful. The | ||
| 126 | * allocation may have same address, or may have been relocated. In either | ||
| 127 | * case, the allocation now has size of `new_len`. A `NULL` return value | ||
| 128 | * indicates that the resize would be equivalent to allocating new memory, | ||
| 129 | * copying the bytes from the old memory, and then freeing the old memory. | ||
| 130 | * In such case, it is more efficient for the caller to perform the copy. | ||
| 131 | * | ||
| 132 | * `new_len` must be greater than zero. | ||
| 133 | * | ||
| 134 | * @param ctx The allocator context | ||
| 135 | * @param memory Pointer to the memory block to remap | ||
| 136 | * @param memory_len Current size of the memory block | ||
| 137 | * @param alignment Alignment (must match original allocation) | ||
| 138 | * @param new_len New requested size | ||
| 139 | * @param ret_addr First return address of the allocation call stack (0 if not provided) | ||
| 140 | * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed | ||
| 141 | */ | ||
| 142 | void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); | ||
| 143 | |||
| 144 | /** | ||
| 145 | * Free and invalidate a region of memory. | ||
| 146 | * | ||
| 147 | * `memory_len` must equal the length requested from the most recent | ||
| 148 | * successful call to `alloc`, `resize`, or `remap`. `alignment` must | ||
| 149 | * equal the same value that was passed as the `alignment` parameter to | ||
| 150 | * the original `alloc` call. | ||
| 151 | * | ||
| 152 | * @param ctx The allocator context | ||
| 153 | * @param memory Pointer to the memory block to free | ||
| 154 | * @param memory_len Size of the memory block | ||
| 155 | * @param alignment Alignment (must match original allocation) | ||
| 156 | * @param ret_addr First return address of the allocation call stack (0 if not provided) | ||
| 157 | */ | ||
| 158 | void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); | ||
| 159 | } GhosttyAllocatorVtable; | ||
| 160 | |||
| 161 | /** | ||
| 162 | * Custom memory allocator. | ||
| 163 | * | ||
| 164 | * For functions that take an allocator pointer, a NULL pointer indicates | ||
| 165 | * that the default allocator should be used. The default allocator will | ||
| 166 | * be libc malloc/free if we're linking to libc. If libc isn't linked, | ||
| 167 | * a custom allocator is used (currently Zig's SMP allocator). | ||
| 168 | * | ||
| 169 | * @ingroup allocator | ||
| 170 | * | ||
| 171 | * Usage example: | ||
| 172 | * @code | ||
| 173 | * GhosttyAllocator allocator = { | ||
| 174 | * .vtable = &my_allocator_vtable, | ||
| 175 | * .ctx = my_allocator_state | ||
| 176 | * }; | ||
| 177 | * @endcode | ||
| 178 | */ | ||
| 179 | typedef struct GhosttyAllocator { | ||
| 180 | /** | ||
| 181 | * Opaque context pointer passed to all vtable functions. | ||
| 182 | * This allows the allocator implementation to maintain state | ||
| 183 | * or reference external resources needed for memory management. | ||
| 184 | */ | ||
| 185 | void *ctx; | ||
| 186 | |||
| 187 | /** | ||
| 188 | * Pointer to the allocator's vtable containing function pointers | ||
| 189 | * for memory operations (alloc, resize, remap, free). | ||
| 190 | */ | ||
| 191 | const GhosttyAllocatorVtable *vtable; | ||
| 192 | } GhosttyAllocator; | ||
| 193 | |||
| 194 | /** @} */ | ||
| 195 | |||
| 196 | #endif /* GHOSTTY_VT_ALLOCATOR_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h created+96| ... | @@ -0,0 +1,96 @@ | ||
| 1 | /** | ||
| 2 | * @file color.h | ||
| 3 | * | ||
| 4 | * Color types and utilities. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_COLOR_H | ||
| 8 | #define GHOSTTY_VT_COLOR_H | ||
| 9 | |||
| 10 | #include <stdint.h> | ||
| 11 | |||
| 12 | #ifdef __cplusplus | ||
| 13 | extern "C" { | ||
| 14 | #endif | ||
| 15 | |||
| 16 | /** | ||
| 17 | * RGB color value. | ||
| 18 | * | ||
| 19 | * @ingroup sgr | ||
| 20 | */ | ||
| 21 | typedef struct { | ||
| 22 | uint8_t r; /**< Red component (0-255) */ | ||
| 23 | uint8_t g; /**< Green component (0-255) */ | ||
| 24 | uint8_t b; /**< Blue component (0-255) */ | ||
| 25 | } GhosttyColorRgb; | ||
| 26 | |||
| 27 | /** | ||
| 28 | * Palette color index (0-255). | ||
| 29 | * | ||
| 30 | * @ingroup sgr | ||
| 31 | */ | ||
| 32 | typedef uint8_t GhosttyColorPaletteIndex; | ||
| 33 | |||
| 34 | /** @addtogroup sgr | ||
| 35 | * @{ | ||
| 36 | */ | ||
| 37 | |||
| 38 | /** Black color (0) @ingroup sgr */ | ||
| 39 | #define GHOSTTY_COLOR_NAMED_BLACK 0 | ||
| 40 | /** Red color (1) @ingroup sgr */ | ||
| 41 | #define GHOSTTY_COLOR_NAMED_RED 1 | ||
| 42 | /** Green color (2) @ingroup sgr */ | ||
| 43 | #define GHOSTTY_COLOR_NAMED_GREEN 2 | ||
| 44 | /** Yellow color (3) @ingroup sgr */ | ||
| 45 | #define GHOSTTY_COLOR_NAMED_YELLOW 3 | ||
| 46 | /** Blue color (4) @ingroup sgr */ | ||
| 47 | #define GHOSTTY_COLOR_NAMED_BLUE 4 | ||
| 48 | /** Magenta color (5) @ingroup sgr */ | ||
| 49 | #define GHOSTTY_COLOR_NAMED_MAGENTA 5 | ||
| 50 | /** Cyan color (6) @ingroup sgr */ | ||
| 51 | #define GHOSTTY_COLOR_NAMED_CYAN 6 | ||
| 52 | /** White color (7) @ingroup sgr */ | ||
| 53 | #define GHOSTTY_COLOR_NAMED_WHITE 7 | ||
| 54 | /** Bright black color (8) @ingroup sgr */ | ||
| 55 | #define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 | ||
| 56 | /** Bright red color (9) @ingroup sgr */ | ||
| 57 | #define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 | ||
| 58 | /** Bright green color (10) @ingroup sgr */ | ||
| 59 | #define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 | ||
| 60 | /** Bright yellow color (11) @ingroup sgr */ | ||
| 61 | #define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 | ||
| 62 | /** Bright blue color (12) @ingroup sgr */ | ||
| 63 | #define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 | ||
| 64 | /** Bright magenta color (13) @ingroup sgr */ | ||
| 65 | #define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 | ||
| 66 | /** Bright cyan color (14) @ingroup sgr */ | ||
| 67 | #define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 | ||
| 68 | /** Bright white color (15) @ingroup sgr */ | ||
| 69 | #define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 | ||
| 70 | |||
| 71 | /** @} */ | ||
| 72 | |||
| 73 | /** | ||
| 74 | * Get the RGB color components. | ||
| 75 | * | ||
| 76 | * This function extracts the individual red, green, and blue components | ||
| 77 | * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments | ||
| 78 | * where accessing struct fields directly is difficult. | ||
| 79 | * | ||
| 80 | * @param color The RGB color value | ||
| 81 | * @param r Pointer to store the red component (0-255) | ||
| 82 | * @param g Pointer to store the green component (0-255) | ||
| 83 | * @param b Pointer to store the blue component (0-255) | ||
| 84 | * | ||
| 85 | * @ingroup sgr | ||
| 86 | */ | ||
| 87 | void ghostty_color_rgb_get(GhosttyColorRgb color, | ||
| 88 | uint8_t* r, | ||
| 89 | uint8_t* g, | ||
| 90 | uint8_t* b); | ||
| 91 | |||
| 92 | #ifdef __cplusplus | ||
| 93 | } | ||
| 94 | #endif | ||
| 95 | |||
| 96 | #endif /* GHOSTTY_VT_COLOR_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h created+80| ... | @@ -0,0 +1,80 @@ | ||
| 1 | /** | ||
| 2 | * @file key.h | ||
| 3 | * | ||
| 4 | * Key encoding module - encode key events into terminal escape sequences. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_KEY_H | ||
| 8 | #define GHOSTTY_VT_KEY_H | ||
| 9 | |||
| 10 | /** @defgroup key Key Encoding | ||
| 11 | * | ||
| 12 | * Utilities for encoding key events into terminal escape sequences, | ||
| 13 | * supporting both legacy encoding as well as Kitty Keyboard Protocol. | ||
| 14 | * | ||
| 15 | * ## Basic Usage | ||
| 16 | * | ||
| 17 | * 1. Create an encoder instance with ghostty_key_encoder_new() | ||
| 18 | * 2. Configure encoder options with ghostty_key_encoder_setopt(). | ||
| 19 | * 3. For each key event: | ||
| 20 | * - Create a key event with ghostty_key_event_new() | ||
| 21 | * - Set event properties (action, key, modifiers, etc.) | ||
| 22 | * - Encode with ghostty_key_encoder_encode() | ||
| 23 | * - Free the event with ghostty_key_event_free() | ||
| 24 | * - Note: You can also reuse the same key event multiple times by | ||
| 25 | * changing its properties. | ||
| 26 | * 4. Free the encoder with ghostty_key_encoder_free() when done | ||
| 27 | * | ||
| 28 | * ## Example | ||
| 29 | * | ||
| 30 | * @code{.c} | ||
| 31 | * #include <assert.h> | ||
| 32 | * #include <stdio.h> | ||
| 33 | * #include <ghostty/vt.h> | ||
| 34 | * | ||
| 35 | * int main() { | ||
| 36 | * // Create encoder | ||
| 37 | * GhosttyKeyEncoder encoder; | ||
| 38 | * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); | ||
| 39 | * assert(result == GHOSTTY_SUCCESS); | ||
| 40 | * | ||
| 41 | * // Enable Kitty keyboard protocol with all features | ||
| 42 | * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, | ||
| 43 | * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); | ||
| 44 | * | ||
| 45 | * // Create and configure key event for Ctrl+C press | ||
| 46 | * GhosttyKeyEvent event; | ||
| 47 | * result = ghostty_key_event_new(NULL, &event); | ||
| 48 | * assert(result == GHOSTTY_SUCCESS); | ||
| 49 | * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); | ||
| 50 | * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); | ||
| 51 | * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); | ||
| 52 | * | ||
| 53 | * // Encode the key event | ||
| 54 | * char buf[128]; | ||
| 55 | * size_t written = 0; | ||
| 56 | * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); | ||
| 57 | * assert(result == GHOSTTY_SUCCESS); | ||
| 58 | * | ||
| 59 | * // Use the encoded sequence (e.g., write to terminal) | ||
| 60 | * fwrite(buf, 1, written, stdout); | ||
| 61 | * | ||
| 62 | * // Cleanup | ||
| 63 | * ghostty_key_event_free(event); | ||
| 64 | * ghostty_key_encoder_free(encoder); | ||
| 65 | * return 0; | ||
| 66 | * } | ||
| 67 | * @endcode | ||
| 68 | * | ||
| 69 | * For a complete working example, see example/c-vt-key-encode in the | ||
| 70 | * repository. | ||
| 71 | * | ||
| 72 | * @{ | ||
| 73 | */ | ||
| 74 | |||
| 75 | #include <ghostty/vt/key/event.h> | ||
| 76 | #include <ghostty/vt/key/encoder.h> | ||
| 77 | |||
| 78 | /** @} */ | ||
| 79 | |||
| 80 | #endif /* GHOSTTY_VT_KEY_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h created+221| ... | @@ -0,0 +1,221 @@ | ||
| 1 | /** | ||
| 2 | * @file encoder.h | ||
| 3 | * | ||
| 4 | * Key event encoding to terminal escape sequences. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_KEY_ENCODER_H | ||
| 8 | #define GHOSTTY_VT_KEY_ENCODER_H | ||
| 9 | |||
| 10 | #include <stddef.h> | ||
| 11 | #include <stdint.h> | ||
| 12 | #include <ghostty/vt/result.h> | ||
| 13 | #include <ghostty/vt/allocator.h> | ||
| 14 | #include <ghostty/vt/key/event.h> | ||
| 15 | |||
| 16 | /** | ||
| 17 | * Opaque handle to a key encoder instance. | ||
| 18 | * | ||
| 19 | * This handle represents a key encoder that converts key events into terminal | ||
| 20 | * escape sequences. | ||
| 21 | * | ||
| 22 | * @ingroup key | ||
| 23 | */ | ||
| 24 | typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; | ||
| 25 | |||
| 26 | /** | ||
| 27 | * Kitty keyboard protocol flags. | ||
| 28 | * | ||
| 29 | * Bitflags representing the various modes of the Kitty keyboard protocol. | ||
| 30 | * These can be combined using bitwise OR operations. Valid values all | ||
| 31 | * start with `GHOSTTY_KITTY_KEY_`. | ||
| 32 | * | ||
| 33 | * @ingroup key | ||
| 34 | */ | ||
| 35 | typedef uint8_t GhosttyKittyKeyFlags; | ||
| 36 | |||
| 37 | /** Kitty keyboard protocol disabled (all flags off) */ | ||
| 38 | #define GHOSTTY_KITTY_KEY_DISABLED 0 | ||
| 39 | |||
| 40 | /** Disambiguate escape codes */ | ||
| 41 | #define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) | ||
| 42 | |||
| 43 | /** Report key press and release events */ | ||
| 44 | #define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) | ||
| 45 | |||
| 46 | /** Report alternate key codes */ | ||
| 47 | #define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) | ||
| 48 | |||
| 49 | /** Report all key events including those normally handled by the terminal */ | ||
| 50 | #define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) | ||
| 51 | |||
| 52 | /** Report associated text with key events */ | ||
| 53 | #define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) | ||
| 54 | |||
| 55 | /** All Kitty keyboard protocol flags enabled */ | ||
| 56 | #define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) | ||
| 57 | |||
| 58 | /** | ||
| 59 | * macOS option key behavior. | ||
| 60 | * | ||
| 61 | * Determines whether the "option" key on macOS is treated as "alt" or not. | ||
| 62 | * See the Ghostty `macos-option-as-alt` configuration option for more details. | ||
| 63 | * | ||
| 64 | * @ingroup key | ||
| 65 | */ | ||
| 66 | typedef enum { | ||
| 67 | /** Option key is not treated as alt */ | ||
| 68 | GHOSTTY_OPTION_AS_ALT_FALSE = 0, | ||
| 69 | /** Option key is treated as alt */ | ||
| 70 | GHOSTTY_OPTION_AS_ALT_TRUE = 1, | ||
| 71 | /** Only left option key is treated as alt */ | ||
| 72 | GHOSTTY_OPTION_AS_ALT_LEFT = 2, | ||
| 73 | /** Only right option key is treated as alt */ | ||
| 74 | GHOSTTY_OPTION_AS_ALT_RIGHT = 3, | ||
| 75 | } GhosttyOptionAsAlt; | ||
| 76 | |||
| 77 | /** | ||
| 78 | * Key encoder option identifiers. | ||
| 79 | * | ||
| 80 | * These values are used with ghostty_key_encoder_setopt() to configure | ||
| 81 | * the behavior of the key encoder. | ||
| 82 | * | ||
| 83 | * @ingroup key | ||
| 84 | */ | ||
| 85 | typedef enum { | ||
| 86 | /** Terminal DEC mode 1: cursor key application mode (value: bool) */ | ||
| 87 | GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, | ||
| 88 | |||
| 89 | /** Terminal DEC mode 66: keypad key application mode (value: bool) */ | ||
| 90 | GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, | ||
| 91 | |||
| 92 | /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ | ||
| 93 | GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, | ||
| 94 | |||
| 95 | /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ | ||
| 96 | GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, | ||
| 97 | |||
| 98 | /** xterm modifyOtherKeys mode 2 (value: bool) */ | ||
| 99 | GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, | ||
| 100 | |||
| 101 | /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ | ||
| 102 | GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, | ||
| 103 | |||
| 104 | /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ | ||
| 105 | GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, | ||
| 106 | } GhosttyKeyEncoderOption; | ||
| 107 | |||
| 108 | /** | ||
| 109 | * Create a new key encoder instance. | ||
| 110 | * | ||
| 111 | * Creates a new key encoder with default options. The encoder can be configured | ||
| 112 | * using ghostty_key_encoder_setopt() and must be freed using | ||
| 113 | * ghostty_key_encoder_free() when no longer needed. | ||
| 114 | * | ||
| 115 | * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator | ||
| 116 | * @param encoder Pointer to store the created encoder handle | ||
| 117 | * @return GHOSTTY_SUCCESS on success, or an error code on failure | ||
| 118 | * | ||
| 119 | * @ingroup key | ||
| 120 | */ | ||
| 121 | GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); | ||
| 122 | |||
| 123 | /** | ||
| 124 | * Free a key encoder instance. | ||
| 125 | * | ||
| 126 | * Releases all resources associated with the key encoder. After this call, | ||
| 127 | * the encoder handle becomes invalid and must not be used. | ||
| 128 | * | ||
| 129 | * @param encoder The encoder handle to free (may be NULL) | ||
| 130 | * | ||
| 131 | * @ingroup key | ||
| 132 | */ | ||
| 133 | void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); | ||
| 134 | |||
| 135 | /** | ||
| 136 | * Set an option on the key encoder. | ||
| 137 | * | ||
| 138 | * Configures the behavior of the key encoder. Options control various aspects | ||
| 139 | * of encoding such as terminal modes (cursor key application mode, keypad mode), | ||
| 140 | * protocol selection (Kitty keyboard protocol flags), and platform-specific | ||
| 141 | * behaviors (macOS option-as-alt). | ||
| 142 | * | ||
| 143 | * A null pointer value does nothing. It does not reset the value to the | ||
| 144 | * default. The setopt call will do nothing. | ||
| 145 | * | ||
| 146 | * @param encoder The encoder handle, must not be NULL | ||
| 147 | * @param option The option to set | ||
| 148 | * @param value Pointer to the value to set (type depends on the option) | ||
| 149 | * | ||
| 150 | * @ingroup key | ||
| 151 | */ | ||
| 152 | void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); | ||
| 153 | |||
| 154 | /** | ||
| 155 | * Encode a key event into a terminal escape sequence. | ||
| 156 | * | ||
| 157 | * Converts a key event into the appropriate terminal escape sequence based on | ||
| 158 | * the encoder's current options. The sequence is written to the provided buffer. | ||
| 159 | * | ||
| 160 | * Not all key events produce output. For example, unmodified modifier keys | ||
| 161 | * typically don't generate escape sequences. Check the out_len parameter to | ||
| 162 | * determine if any data was written. | ||
| 163 | * | ||
| 164 | * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY | ||
| 165 | * and out_len will contain the required buffer size. The caller can then | ||
| 166 | * allocate a larger buffer and call the function again. | ||
| 167 | * | ||
| 168 | * @param encoder The encoder handle, must not be NULL | ||
| 169 | * @param event The key event to encode, must not be NULL | ||
| 170 | * @param out_buf Buffer to write the encoded sequence to | ||
| 171 | * @param out_buf_size Size of the output buffer in bytes | ||
| 172 | * @param out_len Pointer to store the number of bytes written (may be NULL) | ||
| 173 | * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code | ||
| 174 | * | ||
| 175 | * ## Example: Calculate required buffer size | ||
| 176 | * | ||
| 177 | * @code{.c} | ||
| 178 | * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) | ||
| 179 | * size_t required = 0; | ||
| 180 | * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); | ||
| 181 | * assert(result == GHOSTTY_OUT_OF_MEMORY); | ||
| 182 | * | ||
| 183 | * // Allocate buffer of required size | ||
| 184 | * char *buf = malloc(required); | ||
| 185 | * | ||
| 186 | * // Encode with properly sized buffer | ||
| 187 | * size_t written = 0; | ||
| 188 | * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); | ||
| 189 | * assert(result == GHOSTTY_SUCCESS); | ||
| 190 | * | ||
| 191 | * // Use the encoded sequence... | ||
| 192 | * | ||
| 193 | * free(buf); | ||
| 194 | * @endcode | ||
| 195 | * | ||
| 196 | * ## Example: Direct encoding with static buffer | ||
| 197 | * | ||
| 198 | * @code{.c} | ||
| 199 | * // Most escape sequences are short, so a static buffer often suffices | ||
| 200 | * char buf[128]; | ||
| 201 | * size_t written = 0; | ||
| 202 | * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); | ||
| 203 | * | ||
| 204 | * if (result == GHOSTTY_SUCCESS) { | ||
| 205 | * // Write the encoded sequence to the terminal | ||
| 206 | * write(pty_fd, buf, written); | ||
| 207 | * } else if (result == GHOSTTY_OUT_OF_MEMORY) { | ||
| 208 | * // Buffer too small, written contains required size | ||
| 209 | * char *dynamic_buf = malloc(written); | ||
| 210 | * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); | ||
| 211 | * assert(result == GHOSTTY_SUCCESS); | ||
| 212 | * write(pty_fd, dynamic_buf, written); | ||
| 213 | * free(dynamic_buf); | ||
| 214 | * } | ||
| 215 | * @endcode | ||
| 216 | * | ||
| 217 | * @ingroup key | ||
| 218 | */ | ||
| 219 | GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); | ||
| 220 | |||
| 221 | #endif /* GHOSTTY_VT_KEY_ENCODER_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h created+474| ... | @@ -0,0 +1,474 @@ | ||
| 1 | /** | ||
| 2 | * @file event.h | ||
| 3 | * | ||
| 4 | * Key event representation and manipulation. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_KEY_EVENT_H | ||
| 8 | #define GHOSTTY_VT_KEY_EVENT_H | ||
| 9 | |||
| 10 | #include <stdbool.h> | ||
| 11 | #include <stddef.h> | ||
| 12 | #include <stdint.h> | ||
| 13 | #include <ghostty/vt/result.h> | ||
| 14 | #include <ghostty/vt/allocator.h> | ||
| 15 | |||
| 16 | /** | ||
| 17 | * Opaque handle to a key event. | ||
| 18 | * | ||
| 19 | * This handle represents a keyboard input event containing information about | ||
| 20 | * the physical key pressed, modifiers, and generated text. | ||
| 21 | * | ||
| 22 | * @ingroup key | ||
| 23 | */ | ||
| 24 | typedef struct GhosttyKeyEvent *GhosttyKeyEvent; | ||
| 25 | |||
| 26 | /** | ||
| 27 | * Keyboard input event types. | ||
| 28 | * | ||
| 29 | * @ingroup key | ||
| 30 | */ | ||
| 31 | typedef enum { | ||
| 32 | /** Key was released */ | ||
| 33 | GHOSTTY_KEY_ACTION_RELEASE = 0, | ||
| 34 | /** Key was pressed */ | ||
| 35 | GHOSTTY_KEY_ACTION_PRESS = 1, | ||
| 36 | /** Key is being repeated (held down) */ | ||
| 37 | GHOSTTY_KEY_ACTION_REPEAT = 2, | ||
| 38 | } GhosttyKeyAction; | ||
| 39 | |||
| 40 | /** | ||
| 41 | * Keyboard modifier keys bitmask. | ||
| 42 | * | ||
| 43 | * A bitmask representing all keyboard modifiers. This tracks which modifier keys | ||
| 44 | * are pressed and, where supported by the platform, which side (left or right) | ||
| 45 | * of each modifier is active. | ||
| 46 | * | ||
| 47 | * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. | ||
| 48 | * | ||
| 49 | * Modifier side bits are only meaningful when the corresponding modifier bit is set. | ||
| 50 | * Not all platforms support distinguishing between left and right modifier | ||
| 51 | * keys and Ghostty is built to expect that some platforms may not provide this | ||
| 52 | * information. | ||
| 53 | * | ||
| 54 | * @ingroup key | ||
| 55 | */ | ||
| 56 | typedef uint16_t GhosttyMods; | ||
| 57 | |||
| 58 | /** Shift key is pressed */ | ||
| 59 | #define GHOSTTY_MODS_SHIFT (1 << 0) | ||
| 60 | /** Control key is pressed */ | ||
| 61 | #define GHOSTTY_MODS_CTRL (1 << 1) | ||
| 62 | /** Alt/Option key is pressed */ | ||
| 63 | #define GHOSTTY_MODS_ALT (1 << 2) | ||
| 64 | /** Super/Command/Windows key is pressed */ | ||
| 65 | #define GHOSTTY_MODS_SUPER (1 << 3) | ||
| 66 | /** Caps Lock is active */ | ||
| 67 | #define GHOSTTY_MODS_CAPS_LOCK (1 << 4) | ||
| 68 | /** Num Lock is active */ | ||
| 69 | #define GHOSTTY_MODS_NUM_LOCK (1 << 5) | ||
| 70 | |||
| 71 | /** | ||
| 72 | * Right shift is pressed (0 = left, 1 = right). | ||
| 73 | * Only meaningful when GHOSTTY_MODS_SHIFT is set. | ||
| 74 | */ | ||
| 75 | #define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) | ||
| 76 | /** | ||
| 77 | * Right ctrl is pressed (0 = left, 1 = right). | ||
| 78 | * Only meaningful when GHOSTTY_MODS_CTRL is set. | ||
| 79 | */ | ||
| 80 | #define GHOSTTY_MODS_CTRL_SIDE (1 << 7) | ||
| 81 | /** | ||
| 82 | * Right alt is pressed (0 = left, 1 = right). | ||
| 83 | * Only meaningful when GHOSTTY_MODS_ALT is set. | ||
| 84 | */ | ||
| 85 | #define GHOSTTY_MODS_ALT_SIDE (1 << 8) | ||
| 86 | /** | ||
| 87 | * Right super is pressed (0 = left, 1 = right). | ||
| 88 | * Only meaningful when GHOSTTY_MODS_SUPER is set. | ||
| 89 | */ | ||
| 90 | #define GHOSTTY_MODS_SUPER_SIDE (1 << 9) | ||
| 91 | |||
| 92 | /** | ||
| 93 | * Physical key codes. | ||
| 94 | * | ||
| 95 | * The set of key codes that Ghostty is aware of. These represent physical keys | ||
| 96 | * on the keyboard and are layout-independent. For example, the "a" key on a US | ||
| 97 | * keyboard is the same as the "ф" key on a Russian keyboard, but both will | ||
| 98 | * report the same key_a value. | ||
| 99 | * | ||
| 100 | * Layout-dependent strings are provided separately as UTF-8 text and are produced | ||
| 101 | * by the platform. These values are based on the W3C UI Events KeyboardEvent code | ||
| 102 | * standard. See: https://www.w3.org/TR/uievents-code | ||
| 103 | * | ||
| 104 | * @ingroup key | ||
| 105 | */ | ||
| 106 | typedef enum { | ||
| 107 | GHOSTTY_KEY_UNIDENTIFIED = 0, | ||
| 108 | |||
| 109 | // Writing System Keys (W3C § 3.1.1) | ||
| 110 | GHOSTTY_KEY_BACKQUOTE, | ||
| 111 | GHOSTTY_KEY_BACKSLASH, | ||
| 112 | GHOSTTY_KEY_BRACKET_LEFT, | ||
| 113 | GHOSTTY_KEY_BRACKET_RIGHT, | ||
| 114 | GHOSTTY_KEY_COMMA, | ||
| 115 | GHOSTTY_KEY_DIGIT_0, | ||
| 116 | GHOSTTY_KEY_DIGIT_1, | ||
| 117 | GHOSTTY_KEY_DIGIT_2, | ||
| 118 | GHOSTTY_KEY_DIGIT_3, | ||
| 119 | GHOSTTY_KEY_DIGIT_4, | ||
| 120 | GHOSTTY_KEY_DIGIT_5, | ||
| 121 | GHOSTTY_KEY_DIGIT_6, | ||
| 122 | GHOSTTY_KEY_DIGIT_7, | ||
| 123 | GHOSTTY_KEY_DIGIT_8, | ||
| 124 | GHOSTTY_KEY_DIGIT_9, | ||
| 125 | GHOSTTY_KEY_EQUAL, | ||
| 126 | GHOSTTY_KEY_INTL_BACKSLASH, | ||
| 127 | GHOSTTY_KEY_INTL_RO, | ||
| 128 | GHOSTTY_KEY_INTL_YEN, | ||
| 129 | GHOSTTY_KEY_A, | ||
| 130 | GHOSTTY_KEY_B, | ||
| 131 | GHOSTTY_KEY_C, | ||
| 132 | GHOSTTY_KEY_D, | ||
| 133 | GHOSTTY_KEY_E, | ||
| 134 | GHOSTTY_KEY_F, | ||
| 135 | GHOSTTY_KEY_G, | ||
| 136 | GHOSTTY_KEY_H, | ||
| 137 | GHOSTTY_KEY_I, | ||
| 138 | GHOSTTY_KEY_J, | ||
| 139 | GHOSTTY_KEY_K, | ||
| 140 | GHOSTTY_KEY_L, | ||
| 141 | GHOSTTY_KEY_M, | ||
| 142 | GHOSTTY_KEY_N, | ||
| 143 | GHOSTTY_KEY_O, | ||
| 144 | GHOSTTY_KEY_P, | ||
| 145 | GHOSTTY_KEY_Q, | ||
| 146 | GHOSTTY_KEY_R, | ||
| 147 | GHOSTTY_KEY_S, | ||
| 148 | GHOSTTY_KEY_T, | ||
| 149 | GHOSTTY_KEY_U, | ||
| 150 | GHOSTTY_KEY_V, | ||
| 151 | GHOSTTY_KEY_W, | ||
| 152 | GHOSTTY_KEY_X, | ||
| 153 | GHOSTTY_KEY_Y, | ||
| 154 | GHOSTTY_KEY_Z, | ||
| 155 | GHOSTTY_KEY_MINUS, | ||
| 156 | GHOSTTY_KEY_PERIOD, | ||
| 157 | GHOSTTY_KEY_QUOTE, | ||
| 158 | GHOSTTY_KEY_SEMICOLON, | ||
| 159 | GHOSTTY_KEY_SLASH, | ||
| 160 | |||
| 161 | // Functional Keys (W3C § 3.1.2) | ||
| 162 | GHOSTTY_KEY_ALT_LEFT, | ||
| 163 | GHOSTTY_KEY_ALT_RIGHT, | ||
| 164 | GHOSTTY_KEY_BACKSPACE, | ||
| 165 | GHOSTTY_KEY_CAPS_LOCK, | ||
| 166 | GHOSTTY_KEY_CONTEXT_MENU, | ||
| 167 | GHOSTTY_KEY_CONTROL_LEFT, | ||
| 168 | GHOSTTY_KEY_CONTROL_RIGHT, | ||
| 169 | GHOSTTY_KEY_ENTER, | ||
| 170 | GHOSTTY_KEY_META_LEFT, | ||
| 171 | GHOSTTY_KEY_META_RIGHT, | ||
| 172 | GHOSTTY_KEY_SHIFT_LEFT, | ||
| 173 | GHOSTTY_KEY_SHIFT_RIGHT, | ||
| 174 | GHOSTTY_KEY_SPACE, | ||
| 175 | GHOSTTY_KEY_TAB, | ||
| 176 | GHOSTTY_KEY_CONVERT, | ||
| 177 | GHOSTTY_KEY_KANA_MODE, | ||
| 178 | GHOSTTY_KEY_NON_CONVERT, | ||
| 179 | |||
| 180 | // Control Pad Section (W3C § 3.2) | ||
| 181 | GHOSTTY_KEY_DELETE, | ||
| 182 | GHOSTTY_KEY_END, | ||
| 183 | GHOSTTY_KEY_HELP, | ||
| 184 | GHOSTTY_KEY_HOME, | ||
| 185 | GHOSTTY_KEY_INSERT, | ||
| 186 | GHOSTTY_KEY_PAGE_DOWN, | ||
| 187 | GHOSTTY_KEY_PAGE_UP, | ||
| 188 | |||
| 189 | // Arrow Pad Section (W3C § 3.3) | ||
| 190 | GHOSTTY_KEY_ARROW_DOWN, | ||
| 191 | GHOSTTY_KEY_ARROW_LEFT, | ||
| 192 | GHOSTTY_KEY_ARROW_RIGHT, | ||
| 193 | GHOSTTY_KEY_ARROW_UP, | ||
| 194 | |||
| 195 | // Numpad Section (W3C § 3.4) | ||
| 196 | GHOSTTY_KEY_NUM_LOCK, | ||
| 197 | GHOSTTY_KEY_NUMPAD_0, | ||
| 198 | GHOSTTY_KEY_NUMPAD_1, | ||
| 199 | GHOSTTY_KEY_NUMPAD_2, | ||
| 200 | GHOSTTY_KEY_NUMPAD_3, | ||
| 201 | GHOSTTY_KEY_NUMPAD_4, | ||
| 202 | GHOSTTY_KEY_NUMPAD_5, | ||
| 203 | GHOSTTY_KEY_NUMPAD_6, | ||
| 204 | GHOSTTY_KEY_NUMPAD_7, | ||
| 205 | GHOSTTY_KEY_NUMPAD_8, | ||
| 206 | GHOSTTY_KEY_NUMPAD_9, | ||
| 207 | GHOSTTY_KEY_NUMPAD_ADD, | ||
| 208 | GHOSTTY_KEY_NUMPAD_BACKSPACE, | ||
| 209 | GHOSTTY_KEY_NUMPAD_CLEAR, | ||
| 210 | GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, | ||
| 211 | GHOSTTY_KEY_NUMPAD_COMMA, | ||
| 212 | GHOSTTY_KEY_NUMPAD_DECIMAL, | ||
| 213 | GHOSTTY_KEY_NUMPAD_DIVIDE, | ||
| 214 | GHOSTTY_KEY_NUMPAD_ENTER, | ||
| 215 | GHOSTTY_KEY_NUMPAD_EQUAL, | ||
| 216 | GHOSTTY_KEY_NUMPAD_MEMORY_ADD, | ||
| 217 | GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, | ||
| 218 | GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, | ||
| 219 | GHOSTTY_KEY_NUMPAD_MEMORY_STORE, | ||
| 220 | GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, | ||
| 221 | GHOSTTY_KEY_NUMPAD_MULTIPLY, | ||
| 222 | GHOSTTY_KEY_NUMPAD_PAREN_LEFT, | ||
| 223 | GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, | ||
| 224 | GHOSTTY_KEY_NUMPAD_SUBTRACT, | ||
| 225 | GHOSTTY_KEY_NUMPAD_SEPARATOR, | ||
| 226 | GHOSTTY_KEY_NUMPAD_UP, | ||
| 227 | GHOSTTY_KEY_NUMPAD_DOWN, | ||
| 228 | GHOSTTY_KEY_NUMPAD_RIGHT, | ||
| 229 | GHOSTTY_KEY_NUMPAD_LEFT, | ||
| 230 | GHOSTTY_KEY_NUMPAD_BEGIN, | ||
| 231 | GHOSTTY_KEY_NUMPAD_HOME, | ||
| 232 | GHOSTTY_KEY_NUMPAD_END, | ||
| 233 | GHOSTTY_KEY_NUMPAD_INSERT, | ||
| 234 | GHOSTTY_KEY_NUMPAD_DELETE, | ||
| 235 | GHOSTTY_KEY_NUMPAD_PAGE_UP, | ||
| 236 | GHOSTTY_KEY_NUMPAD_PAGE_DOWN, | ||
| 237 | |||
| 238 | // Function Section (W3C § 3.5) | ||
| 239 | GHOSTTY_KEY_ESCAPE, | ||
| 240 | GHOSTTY_KEY_F1, | ||
| 241 | GHOSTTY_KEY_F2, | ||
| 242 | GHOSTTY_KEY_F3, | ||
| 243 | GHOSTTY_KEY_F4, | ||
| 244 | GHOSTTY_KEY_F5, | ||
| 245 | GHOSTTY_KEY_F6, | ||
| 246 | GHOSTTY_KEY_F7, | ||
| 247 | GHOSTTY_KEY_F8, | ||
| 248 | GHOSTTY_KEY_F9, | ||
| 249 | GHOSTTY_KEY_F10, | ||
| 250 | GHOSTTY_KEY_F11, | ||
| 251 | GHOSTTY_KEY_F12, | ||
| 252 | GHOSTTY_KEY_F13, | ||
| 253 | GHOSTTY_KEY_F14, | ||
| 254 | GHOSTTY_KEY_F15, | ||
| 255 | GHOSTTY_KEY_F16, | ||
| 256 | GHOSTTY_KEY_F17, | ||
| 257 | GHOSTTY_KEY_F18, | ||
| 258 | GHOSTTY_KEY_F19, | ||
| 259 | GHOSTTY_KEY_F20, | ||
| 260 | GHOSTTY_KEY_F21, | ||
| 261 | GHOSTTY_KEY_F22, | ||
| 262 | GHOSTTY_KEY_F23, | ||
| 263 | GHOSTTY_KEY_F24, | ||
| 264 | GHOSTTY_KEY_F25, | ||
| 265 | GHOSTTY_KEY_FN, | ||
| 266 | GHOSTTY_KEY_FN_LOCK, | ||
| 267 | GHOSTTY_KEY_PRINT_SCREEN, | ||
| 268 | GHOSTTY_KEY_SCROLL_LOCK, | ||
| 269 | GHOSTTY_KEY_PAUSE, | ||
| 270 | |||
| 271 | // Media Keys (W3C § 3.6) | ||
| 272 | GHOSTTY_KEY_BROWSER_BACK, | ||
| 273 | GHOSTTY_KEY_BROWSER_FAVORITES, | ||
| 274 | GHOSTTY_KEY_BROWSER_FORWARD, | ||
| 275 | GHOSTTY_KEY_BROWSER_HOME, | ||
| 276 | GHOSTTY_KEY_BROWSER_REFRESH, | ||
| 277 | GHOSTTY_KEY_BROWSER_SEARCH, | ||
| 278 | GHOSTTY_KEY_BROWSER_STOP, | ||
| 279 | GHOSTTY_KEY_EJECT, | ||
| 280 | GHOSTTY_KEY_LAUNCH_APP_1, | ||
| 281 | GHOSTTY_KEY_LAUNCH_APP_2, | ||
| 282 | GHOSTTY_KEY_LAUNCH_MAIL, | ||
| 283 | GHOSTTY_KEY_MEDIA_PLAY_PAUSE, | ||
| 284 | GHOSTTY_KEY_MEDIA_SELECT, | ||
| 285 | GHOSTTY_KEY_MEDIA_STOP, | ||
| 286 | GHOSTTY_KEY_MEDIA_TRACK_NEXT, | ||
| 287 | GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, | ||
| 288 | GHOSTTY_KEY_POWER, | ||
| 289 | GHOSTTY_KEY_SLEEP, | ||
| 290 | GHOSTTY_KEY_AUDIO_VOLUME_DOWN, | ||
| 291 | GHOSTTY_KEY_AUDIO_VOLUME_MUTE, | ||
| 292 | GHOSTTY_KEY_AUDIO_VOLUME_UP, | ||
| 293 | GHOSTTY_KEY_WAKE_UP, | ||
| 294 | |||
| 295 | // Legacy, Non-standard, and Special Keys (W3C § 3.7) | ||
| 296 | GHOSTTY_KEY_COPY, | ||
| 297 | GHOSTTY_KEY_CUT, | ||
| 298 | GHOSTTY_KEY_PASTE, | ||
| 299 | } GhosttyKey; | ||
| 300 | |||
| 301 | /** | ||
| 302 | * Create a new key event instance. | ||
| 303 | * | ||
| 304 | * Creates a new key event with default values. The event must be freed using | ||
| 305 | * ghostty_key_event_free() when no longer needed. | ||
| 306 | * | ||
| 307 | * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator | ||
| 308 | * @param event Pointer to store the created key event handle | ||
| 309 | * @return GHOSTTY_SUCCESS on success, or an error code on failure | ||
| 310 | * | ||
| 311 | * @ingroup key | ||
| 312 | */ | ||
| 313 | GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); | ||
| 314 | |||
| 315 | /** | ||
| 316 | * Free a key event instance. | ||
| 317 | * | ||
| 318 | * Releases all resources associated with the key event. After this call, | ||
| 319 | * the event handle becomes invalid and must not be used. | ||
| 320 | * | ||
| 321 | * @param event The key event handle to free (may be NULL) | ||
| 322 | * | ||
| 323 | * @ingroup key | ||
| 324 | */ | ||
| 325 | void ghostty_key_event_free(GhosttyKeyEvent event); | ||
| 326 | |||
| 327 | /** | ||
| 328 | * Set the key action (press, release, repeat). | ||
| 329 | * | ||
| 330 | * @param event The key event handle, must not be NULL | ||
| 331 | * @param action The action to set | ||
| 332 | * | ||
| 333 | * @ingroup key | ||
| 334 | */ | ||
| 335 | void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); | ||
| 336 | |||
| 337 | /** | ||
| 338 | * Get the key action (press, release, repeat). | ||
| 339 | * | ||
| 340 | * @param event The key event handle, must not be NULL | ||
| 341 | * @return The key action | ||
| 342 | * | ||
| 343 | * @ingroup key | ||
| 344 | */ | ||
| 345 | GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); | ||
| 346 | |||
| 347 | /** | ||
| 348 | * Set the physical key code. | ||
| 349 | * | ||
| 350 | * @param event The key event handle, must not be NULL | ||
| 351 | * @param key The physical key code to set | ||
| 352 | * | ||
| 353 | * @ingroup key | ||
| 354 | */ | ||
| 355 | void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); | ||
| 356 | |||
| 357 | /** | ||
| 358 | * Get the physical key code. | ||
| 359 | * | ||
| 360 | * @param event The key event handle, must not be NULL | ||
| 361 | * @return The physical key code | ||
| 362 | * | ||
| 363 | * @ingroup key | ||
| 364 | */ | ||
| 365 | GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); | ||
| 366 | |||
| 367 | /** | ||
| 368 | * Set the modifier keys bitmask. | ||
| 369 | * | ||
| 370 | * @param event The key event handle, must not be NULL | ||
| 371 | * @param mods The modifier keys bitmask to set | ||
| 372 | * | ||
| 373 | * @ingroup key | ||
| 374 | */ | ||
| 375 | void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); | ||
| 376 | |||
| 377 | /** | ||
| 378 | * Get the modifier keys bitmask. | ||
| 379 | * | ||
| 380 | * @param event The key event handle, must not be NULL | ||
| 381 | * @return The modifier keys bitmask | ||
| 382 | * | ||
| 383 | * @ingroup key | ||
| 384 | */ | ||
| 385 | GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); | ||
| 386 | |||
| 387 | /** | ||
| 388 | * Set the consumed modifiers bitmask. | ||
| 389 | * | ||
| 390 | * @param event The key event handle, must not be NULL | ||
| 391 | * @param consumed_mods The consumed modifiers bitmask to set | ||
| 392 | * | ||
| 393 | * @ingroup key | ||
| 394 | */ | ||
| 395 | void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); | ||
| 396 | |||
| 397 | /** | ||
| 398 | * Get the consumed modifiers bitmask. | ||
| 399 | * | ||
| 400 | * @param event The key event handle, must not be NULL | ||
| 401 | * @return The consumed modifiers bitmask | ||
| 402 | * | ||
| 403 | * @ingroup key | ||
| 404 | */ | ||
| 405 | GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); | ||
| 406 | |||
| 407 | /** | ||
| 408 | * Set whether the key event is part of a composition sequence. | ||
| 409 | * | ||
| 410 | * @param event The key event handle, must not be NULL | ||
| 411 | * @param composing Whether the key event is part of a composition sequence | ||
| 412 | * | ||
| 413 | * @ingroup key | ||
| 414 | */ | ||
| 415 | void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); | ||
| 416 | |||
| 417 | /** | ||
| 418 | * Get whether the key event is part of a composition sequence. | ||
| 419 | * | ||
| 420 | * @param event The key event handle, must not be NULL | ||
| 421 | * @return Whether the key event is part of a composition sequence | ||
| 422 | * | ||
| 423 | * @ingroup key | ||
| 424 | */ | ||
| 425 | bool ghostty_key_event_get_composing(GhosttyKeyEvent event); | ||
| 426 | |||
| 427 | /** | ||
| 428 | * Set the UTF-8 text generated by the key event. | ||
| 429 | * | ||
| 430 | * The key event does NOT take ownership of the text pointer. The caller | ||
| 431 | * must ensure the string remains valid for the lifetime needed by the event. | ||
| 432 | * | ||
| 433 | * @param event The key event handle, must not be NULL | ||
| 434 | * @param utf8 The UTF-8 text to set (or NULL for empty) | ||
| 435 | * @param len Length of the UTF-8 text in bytes | ||
| 436 | * | ||
| 437 | * @ingroup key | ||
| 438 | */ | ||
| 439 | void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); | ||
| 440 | |||
| 441 | /** | ||
| 442 | * Get the UTF-8 text generated by the key event. | ||
| 443 | * | ||
| 444 | * The returned pointer is valid until the event is freed or the UTF-8 text is modified. | ||
| 445 | * | ||
| 446 | * @param event The key event handle, must not be NULL | ||
| 447 | * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) | ||
| 448 | * @return The UTF-8 text (or NULL for empty) | ||
| 449 | * | ||
| 450 | * @ingroup key | ||
| 451 | */ | ||
| 452 | const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); | ||
| 453 | |||
| 454 | /** | ||
| 455 | * Set the unshifted Unicode codepoint. | ||
| 456 | * | ||
| 457 | * @param event The key event handle, must not be NULL | ||
| 458 | * @param codepoint The unshifted Unicode codepoint to set | ||
| 459 | * | ||
| 460 | * @ingroup key | ||
| 461 | */ | ||
| 462 | void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); | ||
| 463 | |||
| 464 | /** | ||
| 465 | * Get the unshifted Unicode codepoint. | ||
| 466 | * | ||
| 467 | * @param event The key event handle, must not be NULL | ||
| 468 | * @return The unshifted Unicode codepoint | ||
| 469 | * | ||
| 470 | * @ingroup key | ||
| 471 | */ | ||
| 472 | uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); | ||
| 473 | |||
| 474 | #endif /* GHOSTTY_VT_KEY_EVENT_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h created+233| ... | @@ -0,0 +1,233 @@ | ||
| 1 | /** | ||
| 2 | * @file osc.h | ||
| 3 | * | ||
| 4 | * OSC (Operating System Command) sequence parser and command handling. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_OSC_H | ||
| 8 | #define GHOSTTY_VT_OSC_H | ||
| 9 | |||
| 10 | #include <stdbool.h> | ||
| 11 | #include <stddef.h> | ||
| 12 | #include <stdint.h> | ||
| 13 | #include <ghostty/vt/result.h> | ||
| 14 | #include <ghostty/vt/allocator.h> | ||
| 15 | |||
| 16 | /** | ||
| 17 | * Opaque handle to an OSC parser instance. | ||
| 18 | * | ||
| 19 | * This handle represents an OSC (Operating System Command) parser that can | ||
| 20 | * be used to parse the contents of OSC sequences. | ||
| 21 | * | ||
| 22 | * @ingroup osc | ||
| 23 | */ | ||
| 24 | typedef struct GhosttyOscParser *GhosttyOscParser; | ||
| 25 | |||
| 26 | /** | ||
| 27 | * Opaque handle to a single OSC command. | ||
| 28 | * | ||
| 29 | * This handle represents a parsed OSC (Operating System Command) command. | ||
| 30 | * The command can be queried for its type and associated data. | ||
| 31 | * | ||
| 32 | * @ingroup osc | ||
| 33 | */ | ||
| 34 | typedef struct GhosttyOscCommand *GhosttyOscCommand; | ||
| 35 | |||
| 36 | /** @defgroup osc OSC Parser | ||
| 37 | * | ||
| 38 | * OSC (Operating System Command) sequence parser and command handling. | ||
| 39 | * | ||
| 40 | * The parser operates in a streaming fashion, processing input byte-by-byte | ||
| 41 | * to handle OSC sequences that may arrive in fragments across multiple reads. | ||
| 42 | * This interface makes it easy to integrate into most environments and avoids | ||
| 43 | * over-allocating buffers. | ||
| 44 | * | ||
| 45 | * ## Basic Usage | ||
| 46 | * | ||
| 47 | * 1. Create a parser instance with ghostty_osc_new() | ||
| 48 | * 2. Feed bytes to the parser using ghostty_osc_next() | ||
| 49 | * 3. Finalize parsing with ghostty_osc_end() to get the command | ||
| 50 | * 4. Query command type and extract data using ghostty_osc_command_type() | ||
| 51 | * and ghostty_osc_command_data() | ||
| 52 | * 5. Free the parser with ghostty_osc_free() when done | ||
| 53 | * | ||
| 54 | * @{ | ||
| 55 | */ | ||
| 56 | |||
| 57 | /** | ||
| 58 | * OSC command types. | ||
| 59 | * | ||
| 60 | * @ingroup osc | ||
| 61 | */ | ||
| 62 | typedef enum { | ||
| 63 | GHOSTTY_OSC_COMMAND_INVALID = 0, | ||
| 64 | GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, | ||
| 65 | GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, | ||
| 66 | GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3, | ||
| 67 | GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4, | ||
| 68 | GHOSTTY_OSC_COMMAND_REPORT_PWD = 5, | ||
| 69 | GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6, | ||
| 70 | GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7, | ||
| 71 | GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8, | ||
| 72 | GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9, | ||
| 73 | GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10, | ||
| 74 | GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11, | ||
| 75 | GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12, | ||
| 76 | GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13, | ||
| 77 | GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14, | ||
| 78 | GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15, | ||
| 79 | GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16, | ||
| 80 | GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17, | ||
| 81 | GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18, | ||
| 82 | GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19, | ||
| 83 | GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, | ||
| 84 | GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, | ||
| 85 | GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, | ||
| 86 | } GhosttyOscCommandType; | ||
| 87 | |||
| 88 | /** | ||
| 89 | * OSC command data types. | ||
| 90 | * | ||
| 91 | * These values specify what type of data to extract from an OSC command | ||
| 92 | * using `ghostty_osc_command_data`. | ||
| 93 | * | ||
| 94 | * @ingroup osc | ||
| 95 | */ | ||
| 96 | typedef enum { | ||
| 97 | /** Invalid data type. Never results in any data extraction. */ | ||
| 98 | GHOSTTY_OSC_DATA_INVALID = 0, | ||
| 99 | |||
| 100 | /** | ||
| 101 | * Window title string data. | ||
| 102 | * | ||
| 103 | * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE | ||
| 104 | * | ||
| 105 | * Output type: const char ** (pointer to null-terminated string) | ||
| 106 | * | ||
| 107 | * Lifetime: Valid until the next call to any ghostty_osc_* function with | ||
| 108 | * the same parser instance. Memory is owned by the parser. | ||
| 109 | */ | ||
| 110 | GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, | ||
| 111 | } GhosttyOscCommandData; | ||
| 112 | |||
| 113 | /** | ||
| 114 | * Create a new OSC parser instance. | ||
| 115 | * | ||
| 116 | * Creates a new OSC (Operating System Command) parser using the provided | ||
| 117 | * allocator. The parser must be freed using ghostty_vt_osc_free() when | ||
| 118 | * no longer needed. | ||
| 119 | * | ||
| 120 | * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator | ||
| 121 | * @param parser Pointer to store the created parser handle | ||
| 122 | * @return GHOSTTY_SUCCESS on success, or an error code on failure | ||
| 123 | * | ||
| 124 | * @ingroup osc | ||
| 125 | */ | ||
| 126 | GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); | ||
| 127 | |||
| 128 | /** | ||
| 129 | * Free an OSC parser instance. | ||
| 130 | * | ||
| 131 | * Releases all resources associated with the OSC parser. After this call, | ||
| 132 | * the parser handle becomes invalid and must not be used. | ||
| 133 | * | ||
| 134 | * @param parser The parser handle to free (may be NULL) | ||
| 135 | * | ||
| 136 | * @ingroup osc | ||
| 137 | */ | ||
| 138 | void ghostty_osc_free(GhosttyOscParser parser); | ||
| 139 | |||
| 140 | /** | ||
| 141 | * Reset an OSC parser instance to its initial state. | ||
| 142 | * | ||
| 143 | * Resets the parser state, clearing any partially parsed OSC sequences | ||
| 144 | * and returning the parser to its initial state. This is useful for | ||
| 145 | * reusing a parser instance or recovering from parse errors. | ||
| 146 | * | ||
| 147 | * @param parser The parser handle to reset, must not be null. | ||
| 148 | * | ||
| 149 | * @ingroup osc | ||
| 150 | */ | ||
| 151 | void ghostty_osc_reset(GhosttyOscParser parser); | ||
| 152 | |||
| 153 | /** | ||
| 154 | * Parse the next byte in an OSC sequence. | ||
| 155 | * | ||
| 156 | * Processes a single byte as part of an OSC sequence. The parser maintains | ||
| 157 | * internal state to track the progress through the sequence. Call this | ||
| 158 | * function for each byte in the sequence data. | ||
| 159 | * | ||
| 160 | * When finished pumping the parser with bytes, call ghostty_osc_end | ||
| 161 | * to get the final result. | ||
| 162 | * | ||
| 163 | * @param parser The parser handle, must not be null. | ||
| 164 | * @param byte The next byte to parse | ||
| 165 | * | ||
| 166 | * @ingroup osc | ||
| 167 | */ | ||
| 168 | void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); | ||
| 169 | |||
| 170 | /** | ||
| 171 | * Finalize OSC parsing and retrieve the parsed command. | ||
| 172 | * | ||
| 173 | * Call this function after feeding all bytes of an OSC sequence to the parser | ||
| 174 | * using ghostty_osc_next() with the exception of the terminating character | ||
| 175 | * (ESC or ST). This function finalizes the parsing process and returns the | ||
| 176 | * parsed OSC command. | ||
| 177 | * | ||
| 178 | * The return value is never NULL. Invalid commands will return a command | ||
| 179 | * with type GHOSTTY_OSC_COMMAND_INVALID. | ||
| 180 | * | ||
| 181 | * The terminator parameter specifies the byte that terminated the OSC sequence | ||
| 182 | * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is | ||
| 183 | * preserved in the parsed command so that responses can use the same terminator | ||
| 184 | * format for better compatibility with the calling program. For commands that | ||
| 185 | * do not require a response, this parameter is ignored and the resulting | ||
| 186 | * command will not retain the terminator information. | ||
| 187 | * | ||
| 188 | * The returned command handle is valid until the next call to any | ||
| 189 | * `ghostty_osc_*` function with the same parser instance with the exception | ||
| 190 | * of command introspection functions such as `ghostty_osc_command_type`. | ||
| 191 | * | ||
| 192 | * @param parser The parser handle, must not be null. | ||
| 193 | * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) | ||
| 194 | * @return Handle to the parsed OSC command | ||
| 195 | * | ||
| 196 | * @ingroup osc | ||
| 197 | */ | ||
| 198 | GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); | ||
| 199 | |||
| 200 | /** | ||
| 201 | * Get the type of an OSC command. | ||
| 202 | * | ||
| 203 | * Returns the type identifier for the given OSC command. This can be used | ||
| 204 | * to determine what kind of command was parsed and what data might be | ||
| 205 | * available from it. | ||
| 206 | * | ||
| 207 | * @param command The OSC command handle to query (may be NULL) | ||
| 208 | * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL | ||
| 209 | * | ||
| 210 | * @ingroup osc | ||
| 211 | */ | ||
| 212 | GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); | ||
| 213 | |||
| 214 | /** | ||
| 215 | * Extract data from an OSC command. | ||
| 216 | * | ||
| 217 | * Extracts typed data from the given OSC command based on the specified | ||
| 218 | * data type. The output pointer must be of the appropriate type for the | ||
| 219 | * requested data kind. Valid command types, output types, and memory | ||
| 220 | * safety information are documented in the `GhosttyOscCommandData` enum. | ||
| 221 | * | ||
| 222 | * @param command The OSC command handle to query (may be NULL) | ||
| 223 | * @param data The type of data to extract | ||
| 224 | * @param out Pointer to store the extracted data (type depends on data parameter) | ||
| 225 | * @return true if data extraction was successful, false otherwise | ||
| 226 | * | ||
| 227 | * @ingroup osc | ||
| 228 | */ | ||
| 229 | bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); | ||
| 230 | |||
| 231 | /** @} */ | ||
| 232 | |||
| 233 | #endif /* GHOSTTY_VT_OSC_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h created+75| ... | @@ -0,0 +1,75 @@ | ||
| 1 | /** | ||
| 2 | * @file paste.h | ||
| 3 | * | ||
| 4 | * Paste utilities - validate and encode paste data for terminal input. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_PASTE_H | ||
| 8 | #define GHOSTTY_VT_PASTE_H | ||
| 9 | |||
| 10 | /** @defgroup paste Paste Utilities | ||
| 11 | * | ||
| 12 | * Utilities for validating paste data safety. | ||
| 13 | * | ||
| 14 | * ## Basic Usage | ||
| 15 | * | ||
| 16 | * Use ghostty_paste_is_safe() to check if paste data contains potentially | ||
| 17 | * dangerous sequences before sending it to the terminal. | ||
| 18 | * | ||
| 19 | * ## Example | ||
| 20 | * | ||
| 21 | * @code{.c} | ||
| 22 | * #include <stdio.h> | ||
| 23 | * #include <string.h> | ||
| 24 | * #include <ghostty/vt.h> | ||
| 25 | * | ||
| 26 | * int main() { | ||
| 27 | * const char* safe_data = "hello world"; | ||
| 28 | * const char* unsafe_data = "rm -rf /\n"; | ||
| 29 | * | ||
| 30 | * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { | ||
| 31 | * printf("Safe to paste\n"); | ||
| 32 | * } | ||
| 33 | * | ||
| 34 | * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { | ||
| 35 | * printf("Unsafe! Contains newline\n"); | ||
| 36 | * } | ||
| 37 | * | ||
| 38 | * return 0; | ||
| 39 | * } | ||
| 40 | * @endcode | ||
| 41 | * | ||
| 42 | * @{ | ||
| 43 | */ | ||
| 44 | |||
| 45 | #include <stdbool.h> | ||
| 46 | #include <stddef.h> | ||
| 47 | |||
| 48 | #ifdef __cplusplus | ||
| 49 | extern "C" { | ||
| 50 | #endif | ||
| 51 | |||
| 52 | /** | ||
| 53 | * Check if paste data is safe to paste into the terminal. | ||
| 54 | * | ||
| 55 | * Data is considered unsafe if it contains: | ||
| 56 | * - Newlines (`\n`) which can inject commands | ||
| 57 | * - The bracketed paste end sequence (`\x1b[201~`) which can be used | ||
| 58 | * to exit bracketed paste mode and inject commands | ||
| 59 | * | ||
| 60 | * This check is conservative and considers data unsafe regardless of | ||
| 61 | * current terminal state. | ||
| 62 | * | ||
| 63 | * @param data The paste data to check (must not be NULL) | ||
| 64 | * @param len The length of the data in bytes | ||
| 65 | * @return true if the data is safe to paste, false otherwise | ||
| 66 | */ | ||
| 67 | bool ghostty_paste_is_safe(const char* data, size_t len); | ||
| 68 | |||
| 69 | #ifdef __cplusplus | ||
| 70 | } | ||
| 71 | #endif | ||
| 72 | |||
| 73 | /** @} */ | ||
| 74 | |||
| 75 | #endif /* GHOSTTY_VT_PASTE_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | /** | ||
| 2 | * @file result.h | ||
| 3 | * | ||
| 4 | * Result codes for libghostty-vt operations. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_RESULT_H | ||
| 8 | #define GHOSTTY_VT_RESULT_H | ||
| 9 | |||
| 10 | /** | ||
| 11 | * Result codes for libghostty-vt operations. | ||
| 12 | */ | ||
| 13 | typedef enum { | ||
| 14 | /** Operation completed successfully */ | ||
| 15 | GHOSTTY_SUCCESS = 0, | ||
| 16 | /** Operation failed due to failed allocation */ | ||
| 17 | GHOSTTY_OUT_OF_MEMORY = -1, | ||
| 18 | /** Operation failed due to invalid value */ | ||
| 19 | GHOSTTY_INVALID_VALUE = -2, | ||
| 20 | } GhosttyResult; | ||
| 21 | |||
| 22 | #endif /* GHOSTTY_VT_RESULT_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h created+394| ... | @@ -0,0 +1,394 @@ | ||
| 1 | /** | ||
| 2 | * @file sgr.h | ||
| 3 | * | ||
| 4 | * SGR (Select Graphic Rendition) attribute parsing and handling. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_SGR_H | ||
| 8 | #define GHOSTTY_VT_SGR_H | ||
| 9 | |||
| 10 | /** @defgroup sgr SGR Parser | ||
| 11 | * | ||
| 12 | * SGR (Select Graphic Rendition) attribute parser. | ||
| 13 | * | ||
| 14 | * SGR sequences are the syntax used to set styling attributes such as | ||
| 15 | * bold, italic, underline, and colors for text in terminal emulators. | ||
| 16 | * For example, you may be familiar with sequences like `ESC[1;31m`. The | ||
| 17 | * `1;31` is the SGR attribute list. | ||
| 18 | * | ||
| 19 | * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) | ||
| 20 | * and returns individual text attributes like bold, italic, colors, etc. | ||
| 21 | * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, | ||
| 22 | * and handles various color formats including 8-color, 16-color, 256-color, | ||
| 23 | * X11 named colors, and RGB in multiple formats. | ||
| 24 | * | ||
| 25 | * ## Basic Usage | ||
| 26 | * | ||
| 27 | * 1. Create a parser instance with ghostty_sgr_new() | ||
| 28 | * 2. Set SGR parameters with ghostty_sgr_set_params() | ||
| 29 | * 3. Iterate through attributes using ghostty_sgr_next() | ||
| 30 | * 4. Free the parser with ghostty_sgr_free() when done | ||
| 31 | * | ||
| 32 | * ## Example | ||
| 33 | * | ||
| 34 | * @code{.c} | ||
| 35 | * #include <assert.h> | ||
| 36 | * #include <stdio.h> | ||
| 37 | * #include <ghostty/vt.h> | ||
| 38 | * | ||
| 39 | * int main() { | ||
| 40 | * // Create parser | ||
| 41 | * GhosttySgrParser parser; | ||
| 42 | * GhosttyResult result = ghostty_sgr_new(NULL, &parser); | ||
| 43 | * assert(result == GHOSTTY_SUCCESS); | ||
| 44 | * | ||
| 45 | * // Parse "bold, red foreground" sequence: ESC[1;31m | ||
| 46 | * uint16_t params[] = {1, 31}; | ||
| 47 | * result = ghostty_sgr_set_params(parser, params, NULL, 2); | ||
| 48 | * assert(result == GHOSTTY_SUCCESS); | ||
| 49 | * | ||
| 50 | * // Iterate through attributes | ||
| 51 | * GhosttySgrAttribute attr; | ||
| 52 | * while (ghostty_sgr_next(parser, &attr)) { | ||
| 53 | * switch (attr.tag) { | ||
| 54 | * case GHOSTTY_SGR_ATTR_BOLD: | ||
| 55 | * printf("Bold enabled\n"); | ||
| 56 | * break; | ||
| 57 | * case GHOSTTY_SGR_ATTR_FG_8: | ||
| 58 | * printf("Foreground color: %d\n", attr.value.fg_8); | ||
| 59 | * break; | ||
| 60 | * default: | ||
| 61 | * break; | ||
| 62 | * } | ||
| 63 | * } | ||
| 64 | * | ||
| 65 | * // Cleanup | ||
| 66 | * ghostty_sgr_free(parser); | ||
| 67 | * return 0; | ||
| 68 | * } | ||
| 69 | * @endcode | ||
| 70 | * | ||
| 71 | * @{ | ||
| 72 | */ | ||
| 73 | |||
| 74 | #include <ghostty/vt/allocator.h> | ||
| 75 | #include <ghostty/vt/color.h> | ||
| 76 | #include <ghostty/vt/result.h> | ||
| 77 | #include <stdbool.h> | ||
| 78 | #include <stddef.h> | ||
| 79 | #include <stdint.h> | ||
| 80 | |||
| 81 | #ifdef __cplusplus | ||
| 82 | extern "C" { | ||
| 83 | #endif | ||
| 84 | |||
| 85 | /** | ||
| 86 | * Opaque handle to an SGR parser instance. | ||
| 87 | * | ||
| 88 | * This handle represents an SGR (Select Graphic Rendition) parser that can | ||
| 89 | * be used to parse SGR sequences and extract individual text attributes. | ||
| 90 | * | ||
| 91 | * @ingroup sgr | ||
| 92 | */ | ||
| 93 | typedef struct GhosttySgrParser* GhosttySgrParser; | ||
| 94 | |||
| 95 | /** | ||
| 96 | * SGR attribute tags. | ||
| 97 | * | ||
| 98 | * These values identify the type of an SGR attribute in a tagged union. | ||
| 99 | * Use the tag to determine which field in the attribute value union to access. | ||
| 100 | * | ||
| 101 | * @ingroup sgr | ||
| 102 | */ | ||
| 103 | typedef enum { | ||
| 104 | GHOSTTY_SGR_ATTR_UNSET = 0, | ||
| 105 | GHOSTTY_SGR_ATTR_UNKNOWN = 1, | ||
| 106 | GHOSTTY_SGR_ATTR_BOLD = 2, | ||
| 107 | GHOSTTY_SGR_ATTR_RESET_BOLD = 3, | ||
| 108 | GHOSTTY_SGR_ATTR_ITALIC = 4, | ||
| 109 | GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, | ||
| 110 | GHOSTTY_SGR_ATTR_FAINT = 6, | ||
| 111 | GHOSTTY_SGR_ATTR_UNDERLINE = 7, | ||
| 112 | GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8, | ||
| 113 | GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9, | ||
| 114 | GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10, | ||
| 115 | GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11, | ||
| 116 | GHOSTTY_SGR_ATTR_OVERLINE = 12, | ||
| 117 | GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13, | ||
| 118 | GHOSTTY_SGR_ATTR_BLINK = 14, | ||
| 119 | GHOSTTY_SGR_ATTR_RESET_BLINK = 15, | ||
| 120 | GHOSTTY_SGR_ATTR_INVERSE = 16, | ||
| 121 | GHOSTTY_SGR_ATTR_RESET_INVERSE = 17, | ||
| 122 | GHOSTTY_SGR_ATTR_INVISIBLE = 18, | ||
| 123 | GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19, | ||
| 124 | GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20, | ||
| 125 | GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21, | ||
| 126 | GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22, | ||
| 127 | GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23, | ||
| 128 | GHOSTTY_SGR_ATTR_BG_8 = 24, | ||
| 129 | GHOSTTY_SGR_ATTR_FG_8 = 25, | ||
| 130 | GHOSTTY_SGR_ATTR_RESET_FG = 26, | ||
| 131 | GHOSTTY_SGR_ATTR_RESET_BG = 27, | ||
| 132 | GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28, | ||
| 133 | GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29, | ||
| 134 | GHOSTTY_SGR_ATTR_BG_256 = 30, | ||
| 135 | GHOSTTY_SGR_ATTR_FG_256 = 31, | ||
| 136 | } GhosttySgrAttributeTag; | ||
| 137 | |||
| 138 | /** | ||
| 139 | * Underline style types. | ||
| 140 | * | ||
| 141 | * @ingroup sgr | ||
| 142 | */ | ||
| 143 | typedef enum { | ||
| 144 | GHOSTTY_SGR_UNDERLINE_NONE = 0, | ||
| 145 | GHOSTTY_SGR_UNDERLINE_SINGLE = 1, | ||
| 146 | GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, | ||
| 147 | GHOSTTY_SGR_UNDERLINE_CURLY = 3, | ||
| 148 | GHOSTTY_SGR_UNDERLINE_DOTTED = 4, | ||
| 149 | GHOSTTY_SGR_UNDERLINE_DASHED = 5, | ||
| 150 | } GhosttySgrUnderline; | ||
| 151 | |||
| 152 | /** | ||
| 153 | * Unknown SGR attribute data. | ||
| 154 | * | ||
| 155 | * Contains the full parameter list and the partial list where parsing | ||
| 156 | * encountered an unknown or invalid sequence. | ||
| 157 | * | ||
| 158 | * @ingroup sgr | ||
| 159 | */ | ||
| 160 | typedef struct { | ||
| 161 | const uint16_t* full_ptr; | ||
| 162 | size_t full_len; | ||
| 163 | const uint16_t* partial_ptr; | ||
| 164 | size_t partial_len; | ||
| 165 | } GhosttySgrUnknown; | ||
| 166 | |||
| 167 | /** | ||
| 168 | * SGR attribute value union. | ||
| 169 | * | ||
| 170 | * This union contains all possible attribute values. Use the tag field | ||
| 171 | * to determine which union member is active. Attributes without associated | ||
| 172 | * data (like bold, italic) don't use the union value. | ||
| 173 | * | ||
| 174 | * @ingroup sgr | ||
| 175 | */ | ||
| 176 | typedef union { | ||
| 177 | GhosttySgrUnknown unknown; | ||
| 178 | GhosttySgrUnderline underline; | ||
| 179 | GhosttyColorRgb underline_color; | ||
| 180 | GhosttyColorPaletteIndex underline_color_256; | ||
| 181 | GhosttyColorRgb direct_color_fg; | ||
| 182 | GhosttyColorRgb direct_color_bg; | ||
| 183 | GhosttyColorPaletteIndex bg_8; | ||
| 184 | GhosttyColorPaletteIndex fg_8; | ||
| 185 | GhosttyColorPaletteIndex bright_bg_8; | ||
| 186 | GhosttyColorPaletteIndex bright_fg_8; | ||
| 187 | GhosttyColorPaletteIndex bg_256; | ||
| 188 | GhosttyColorPaletteIndex fg_256; | ||
| 189 | uint64_t _padding[8]; | ||
| 190 | } GhosttySgrAttributeValue; | ||
| 191 | |||
| 192 | /** | ||
| 193 | * SGR attribute (tagged union). | ||
| 194 | * | ||
| 195 | * A complete SGR attribute with both its type tag and associated value. | ||
| 196 | * Always check the tag field to determine which value union member is valid. | ||
| 197 | * | ||
| 198 | * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be | ||
| 199 | * identified by tag alone; the value union is not used for these and | ||
| 200 | * the memory in the value field is undefined. | ||
| 201 | * | ||
| 202 | * @ingroup sgr | ||
| 203 | */ | ||
| 204 | typedef struct { | ||
| 205 | GhosttySgrAttributeTag tag; | ||
| 206 | GhosttySgrAttributeValue value; | ||
| 207 | } GhosttySgrAttribute; | ||
| 208 | |||
| 209 | /** | ||
| 210 | * Create a new SGR parser instance. | ||
| 211 | * | ||
| 212 | * Creates a new SGR (Select Graphic Rendition) parser using the provided | ||
| 213 | * allocator. The parser must be freed using ghostty_sgr_free() when | ||
| 214 | * no longer needed. | ||
| 215 | * | ||
| 216 | * @param allocator Pointer to the allocator to use for memory management, or | ||
| 217 | * NULL to use the default allocator | ||
| 218 | * @param parser Pointer to store the created parser handle | ||
| 219 | * @return GHOSTTY_SUCCESS on success, or an error code on failure | ||
| 220 | * | ||
| 221 | * @ingroup sgr | ||
| 222 | */ | ||
| 223 | GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, | ||
| 224 | GhosttySgrParser* parser); | ||
| 225 | |||
| 226 | /** | ||
| 227 | * Free an SGR parser instance. | ||
| 228 | * | ||
| 229 | * Releases all resources associated with the SGR parser. After this call, | ||
| 230 | * the parser handle becomes invalid and must not be used. This includes | ||
| 231 | * any attributes previously returned by ghostty_sgr_next(). | ||
| 232 | * | ||
| 233 | * @param parser The parser handle to free (may be NULL) | ||
| 234 | * | ||
| 235 | * @ingroup sgr | ||
| 236 | */ | ||
| 237 | void ghostty_sgr_free(GhosttySgrParser parser); | ||
| 238 | |||
| 239 | /** | ||
| 240 | * Reset an SGR parser instance to the beginning of the parameter list. | ||
| 241 | * | ||
| 242 | * Resets the parser's iteration state without clearing the parameters. | ||
| 243 | * After calling this, ghostty_sgr_next() will start from the beginning | ||
| 244 | * of the parameter list again. | ||
| 245 | * | ||
| 246 | * @param parser The parser handle to reset, must not be NULL | ||
| 247 | * | ||
| 248 | * @ingroup sgr | ||
| 249 | */ | ||
| 250 | void ghostty_sgr_reset(GhosttySgrParser parser); | ||
| 251 | |||
| 252 | /** | ||
| 253 | * Set SGR parameters for parsing. | ||
| 254 | * | ||
| 255 | * Sets the SGR parameter list to parse. Parameters are the numeric values | ||
| 256 | * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). | ||
| 257 | * | ||
| 258 | * The separators array optionally specifies the separator type for each | ||
| 259 | * parameter position. Each byte should be either ';' for semicolon or ':' | ||
| 260 | * for colon. This is needed for certain color formats that use colon | ||
| 261 | * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator | ||
| 262 | * values are treated as semicolons. The separators array must have the same | ||
| 263 | * length as the params array, if it is not NULL. | ||
| 264 | * | ||
| 265 | * If separators is NULL, all parameters are assumed to be semicolon-separated. | ||
| 266 | * | ||
| 267 | * This function makes an internal copy of the parameter and separator data, | ||
| 268 | * so the caller can safely free or modify the input arrays after this call. | ||
| 269 | * | ||
| 270 | * After calling this function, the parser is automatically reset and ready | ||
| 271 | * to iterate from the beginning. | ||
| 272 | * | ||
| 273 | * @param parser The parser handle, must not be NULL | ||
| 274 | * @param params Array of SGR parameter values | ||
| 275 | * @param separators Optional array of separator characters (';' or ':'), or | ||
| 276 | * NULL | ||
| 277 | * @param len Number of parameters (and separators if provided) | ||
| 278 | * @return GHOSTTY_SUCCESS on success, or an error code on failure | ||
| 279 | * | ||
| 280 | * @ingroup sgr | ||
| 281 | */ | ||
| 282 | GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, | ||
| 283 | const uint16_t* params, | ||
| 284 | const char* separators, | ||
| 285 | size_t len); | ||
| 286 | |||
| 287 | /** | ||
| 288 | * Get the next SGR attribute. | ||
| 289 | * | ||
| 290 | * Parses and returns the next attribute from the parameter list. | ||
| 291 | * Call this function repeatedly until it returns false to process | ||
| 292 | * all attributes in the sequence. | ||
| 293 | * | ||
| 294 | * @param parser The parser handle, must not be NULL | ||
| 295 | * @param attr Pointer to store the next attribute | ||
| 296 | * @return true if an attribute was returned, false if no more attributes | ||
| 297 | * | ||
| 298 | * @ingroup sgr | ||
| 299 | */ | ||
| 300 | bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); | ||
| 301 | |||
| 302 | /** | ||
| 303 | * Get the full parameter list from an unknown SGR attribute. | ||
| 304 | * | ||
| 305 | * This function retrieves the full parameter list that was provided to the | ||
| 306 | * parser when an unknown attribute was encountered. Primarily useful in | ||
| 307 | * WebAssembly environments where accessing struct fields directly is difficult. | ||
| 308 | * | ||
| 309 | * @param unknown The unknown attribute data | ||
| 310 | * @param ptr Pointer to store the pointer to the parameter array (may be NULL) | ||
| 311 | * @return The length of the full parameter array | ||
| 312 | * | ||
| 313 | * @ingroup sgr | ||
| 314 | */ | ||
| 315 | size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, | ||
| 316 | const uint16_t** ptr); | ||
| 317 | |||
| 318 | /** | ||
| 319 | * Get the partial parameter list from an unknown SGR attribute. | ||
| 320 | * | ||
| 321 | * This function retrieves the partial parameter list where parsing stopped | ||
| 322 | * when an unknown attribute was encountered. Primarily useful in WebAssembly | ||
| 323 | * environments where accessing struct fields directly is difficult. | ||
| 324 | * | ||
| 325 | * @param unknown The unknown attribute data | ||
| 326 | * @param ptr Pointer to store the pointer to the parameter array (may be NULL) | ||
| 327 | * @return The length of the partial parameter array | ||
| 328 | * | ||
| 329 | * @ingroup sgr | ||
| 330 | */ | ||
| 331 | size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, | ||
| 332 | const uint16_t** ptr); | ||
| 333 | |||
| 334 | /** | ||
| 335 | * Get the tag from an SGR attribute. | ||
| 336 | * | ||
| 337 | * This function extracts the tag that identifies which type of attribute | ||
| 338 | * this is. Primarily useful in WebAssembly environments where accessing | ||
| 339 | * struct fields directly is difficult. | ||
| 340 | * | ||
| 341 | * @param attr The SGR attribute | ||
| 342 | * @return The attribute tag | ||
| 343 | * | ||
| 344 | * @ingroup sgr | ||
| 345 | */ | ||
| 346 | GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); | ||
| 347 | |||
| 348 | /** | ||
| 349 | * Get the value from an SGR attribute. | ||
| 350 | * | ||
| 351 | * This function returns a pointer to the value union from an SGR attribute. Use | ||
| 352 | * the tag to determine which field of the union is valid. Primarily useful in | ||
| 353 | * WebAssembly environments where accessing struct fields directly is difficult. | ||
| 354 | * | ||
| 355 | * @param attr Pointer to the SGR attribute | ||
| 356 | * @return Pointer to the attribute value union | ||
| 357 | * | ||
| 358 | * @ingroup sgr | ||
| 359 | */ | ||
| 360 | GhosttySgrAttributeValue* ghostty_sgr_attribute_value( | ||
| 361 | GhosttySgrAttribute* attr); | ||
| 362 | |||
| 363 | #ifdef __wasm__ | ||
| 364 | /** | ||
| 365 | * Allocate memory for an SGR attribute (WebAssembly only). | ||
| 366 | * | ||
| 367 | * This is a convenience function for WebAssembly environments to allocate | ||
| 368 | * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. | ||
| 369 | * | ||
| 370 | * @return Pointer to the allocated attribute structure | ||
| 371 | * | ||
| 372 | * @ingroup wasm | ||
| 373 | */ | ||
| 374 | GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); | ||
| 375 | |||
| 376 | /** | ||
| 377 | * Free memory for an SGR attribute (WebAssembly only). | ||
| 378 | * | ||
| 379 | * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. | ||
| 380 | * | ||
| 381 | * @param attr Pointer to the attribute structure to free | ||
| 382 | * | ||
| 383 | * @ingroup wasm | ||
| 384 | */ | ||
| 385 | void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); | ||
| 386 | #endif | ||
| 387 | |||
| 388 | #ifdef __cplusplus | ||
| 389 | } | ||
| 390 | #endif | ||
| 391 | |||
| 392 | /** @} */ | ||
| 393 | |||
| 394 | #endif /* GHOSTTY_VT_SGR_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h created+159| ... | @@ -0,0 +1,159 @@ | ||
| 1 | /** | ||
| 2 | * @file wasm.h | ||
| 3 | * | ||
| 4 | * WebAssembly utility functions for libghostty-vt. | ||
| 5 | */ | ||
| 6 | |||
| 7 | #ifndef GHOSTTY_VT_WASM_H | ||
| 8 | #define GHOSTTY_VT_WASM_H | ||
| 9 | |||
| 10 | #ifdef __wasm__ | ||
| 11 | |||
| 12 | #include <stddef.h> | ||
| 13 | #include <stdint.h> | ||
| 14 | |||
| 15 | /** @defgroup wasm WebAssembly Utilities | ||
| 16 | * | ||
| 17 | * Convenience functions for allocating various types in WebAssembly builds. | ||
| 18 | * **These are only available the libghostty-vt wasm module.** | ||
| 19 | * | ||
| 20 | * Ghostty relies on pointers to various types for ABI compatibility, and | ||
| 21 | * creating those pointers in Wasm can be tedious. These functions provide | ||
| 22 | * a purely additive set of utilities that simplify memory management in | ||
| 23 | * Wasm environments without changing the core C library API. | ||
| 24 | * | ||
| 25 | * @note These functions always use the default allocator. If you need | ||
| 26 | * custom allocation strategies, you should allocate types manually using | ||
| 27 | * your custom allocator. This is a very rare use case in the WebAssembly | ||
| 28 | * world so these are optimized for simplicity. | ||
| 29 | * | ||
| 30 | * ## Example Usage | ||
| 31 | * | ||
| 32 | * Here's a simple example of using the Wasm utilities with the key encoder: | ||
| 33 | * | ||
| 34 | * @code | ||
| 35 | * const { exports } = wasmInstance; | ||
| 36 | * const view = new DataView(wasmMemory.buffer); | ||
| 37 | * | ||
| 38 | * // Create key encoder | ||
| 39 | * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); | ||
| 40 | * exports.ghostty_key_encoder_new(null, encoderPtr); | ||
| 41 | * const encoder = view.getUint32(encoder, true); | ||
| 42 | * | ||
| 43 | * // Configure encoder with Kitty protocol flags | ||
| 44 | * const flagsPtr = exports.ghostty_wasm_alloc_u8(); | ||
| 45 | * view.setUint8(flagsPtr, 0x1F); | ||
| 46 | * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); | ||
| 47 | * | ||
| 48 | * // Allocate output buffer and size pointer | ||
| 49 | * const bufferSize = 32; | ||
| 50 | * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); | ||
| 51 | * const writtenPtr = exports.ghostty_wasm_alloc_usize(); | ||
| 52 | * | ||
| 53 | * // Encode the key event | ||
| 54 | * exports.ghostty_key_encoder_encode( | ||
| 55 | * encoder, eventPtr, bufPtr, bufferSize, writtenPtr | ||
| 56 | * ); | ||
| 57 | * | ||
| 58 | * // Read encoded output | ||
| 59 | * const bytesWritten = view.getUint32(writtenPtr, true); | ||
| 60 | * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); | ||
| 61 | * @endcode | ||
| 62 | * | ||
| 63 | * @remark The code above is pretty ugly! This is the lowest level interface | ||
| 64 | * to the libghostty-vt Wasm module. In practice, this should be wrapped | ||
| 65 | * in a higher-level API that abstracts away all this. | ||
| 66 | * | ||
| 67 | * @{ | ||
| 68 | */ | ||
| 69 | |||
| 70 | /** | ||
| 71 | * Allocate an opaque pointer. This can be used for any opaque pointer | ||
| 72 | * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. | ||
| 73 | * | ||
| 74 | * @return Pointer to allocated opaque pointer, or NULL if allocation failed | ||
| 75 | * @ingroup wasm | ||
| 76 | */ | ||
| 77 | void** ghostty_wasm_alloc_opaque(void); | ||
| 78 | |||
| 79 | /** | ||
| 80 | * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). | ||
| 81 | * | ||
| 82 | * @param ptr Pointer to free, or NULL (NULL is safely ignored) | ||
| 83 | * @ingroup wasm | ||
| 84 | */ | ||
| 85 | void ghostty_wasm_free_opaque(void **ptr); | ||
| 86 | |||
| 87 | /** | ||
| 88 | * Allocate an array of uint8_t values. | ||
| 89 | * | ||
| 90 | * @param len Number of uint8_t elements to allocate | ||
| 91 | * @return Pointer to allocated array, or NULL if allocation failed | ||
| 92 | * @ingroup wasm | ||
| 93 | */ | ||
| 94 | uint8_t* ghostty_wasm_alloc_u8_array(size_t len); | ||
| 95 | |||
| 96 | /** | ||
| 97 | * Free an array allocated by ghostty_wasm_alloc_u8_array(). | ||
| 98 | * | ||
| 99 | * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) | ||
| 100 | * @param len Length of the array (must match the length passed to alloc) | ||
| 101 | * @ingroup wasm | ||
| 102 | */ | ||
| 103 | void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); | ||
| 104 | |||
| 105 | /** | ||
| 106 | * Allocate an array of uint16_t values. | ||
| 107 | * | ||
| 108 | * @param len Number of uint16_t elements to allocate | ||
| 109 | * @return Pointer to allocated array, or NULL if allocation failed | ||
| 110 | * @ingroup wasm | ||
| 111 | */ | ||
| 112 | uint16_t* ghostty_wasm_alloc_u16_array(size_t len); | ||
| 113 | |||
| 114 | /** | ||
| 115 | * Free an array allocated by ghostty_wasm_alloc_u16_array(). | ||
| 116 | * | ||
| 117 | * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) | ||
| 118 | * @param len Length of the array (must match the length passed to alloc) | ||
| 119 | * @ingroup wasm | ||
| 120 | */ | ||
| 121 | void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); | ||
| 122 | |||
| 123 | /** | ||
| 124 | * Allocate a single uint8_t value. | ||
| 125 | * | ||
| 126 | * @return Pointer to allocated uint8_t, or NULL if allocation failed | ||
| 127 | * @ingroup wasm | ||
| 128 | */ | ||
| 129 | uint8_t* ghostty_wasm_alloc_u8(void); | ||
| 130 | |||
| 131 | /** | ||
| 132 | * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). | ||
| 133 | * | ||
| 134 | * @param ptr Pointer to free, or NULL (NULL is safely ignored) | ||
| 135 | * @ingroup wasm | ||
| 136 | */ | ||
| 137 | void ghostty_wasm_free_u8(uint8_t *ptr); | ||
| 138 | |||
| 139 | /** | ||
| 140 | * Allocate a single size_t value. | ||
| 141 | * | ||
| 142 | * @return Pointer to allocated size_t, or NULL if allocation failed | ||
| 143 | * @ingroup wasm | ||
| 144 | */ | ||
| 145 | size_t* ghostty_wasm_alloc_usize(void); | ||
| 146 | |||
| 147 | /** | ||
| 148 | * Free a size_t allocated by ghostty_wasm_alloc_usize(). | ||
| 149 | * | ||
| 150 | * @param ptr Pointer to free, or NULL (NULL is safely ignored) | ||
| 151 | * @ingroup wasm | ||
| 152 | */ | ||
| 153 | void ghostty_wasm_free_usize(size_t *ptr); | ||
| 154 | |||
| 155 | /** @} */ | ||
| 156 | |||
| 157 | #endif /* __wasm__ */ | ||
| 158 | |||
| 159 | #endif /* GHOSTTY_VT_WASM_H */ | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/module.modulemap created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | // This makes Ghostty available to the XCode build for the macOS app. | ||
| 2 | // We append "Kit" to it not to be cute, but because targets have to have | ||
| 3 | // unique names and we use Ghostty for other things. | ||
| 4 | module GhosttyKit { | ||
| 5 | umbrella header "ghostty.h" | ||
| 6 | export * | ||
| 7 | } | ||
apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a created| Binary files /dev/null and b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a differ | |||
apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | d36c3b8dffd0d756dd5e5f4933962f774a0e6753 | ||
apps/mobile/modules/t3-terminal/android/build.gradle created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | apply plugin: 'com.android.library' | ||
| 2 | apply plugin: 'org.jetbrains.kotlin.android' | ||
| 3 | |||
| 4 | group = 'com.t3tools.terminal' | ||
| 5 | version = '0.0.0' | ||
| 6 | |||
| 7 | android { | ||
| 8 | namespace 'expo.modules.t3terminal' | ||
| 9 | compileSdk rootProject.ext.compileSdkVersion | ||
| 10 | |||
| 11 | defaultConfig { | ||
| 12 | minSdkVersion rootProject.ext.minSdkVersion | ||
| 13 | targetSdkVersion rootProject.ext.targetSdkVersion | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | dependencies { | ||
| 18 | implementation project(':expo-modules-core') | ||
| 19 | } | ||
apps/mobile/modules/t3-terminal/android/src/main/AndroidManifest.xml created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" /> | ||
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | package expo.modules.t3terminal | ||
| 2 | |||
| 3 | import expo.modules.kotlin.modules.Module | ||
| 4 | import expo.modules.kotlin.modules.ModuleDefinition | ||
| 5 | |||
| 6 | class T3TerminalModule : Module() { | ||
| 7 | override fun definition() = ModuleDefinition { | ||
| 8 | Name("T3TerminalSurface") | ||
| 9 | |||
| 10 | View(T3TerminalView::class) { | ||
| 11 | Prop("terminalKey") { view: T3TerminalView, terminalKey: String -> | ||
| 12 | view.terminalKey = terminalKey | ||
| 13 | } | ||
| 14 | |||
| 15 | Prop("initialBuffer") { view: T3TerminalView, initialBuffer: String -> | ||
| 16 | view.initialBuffer = initialBuffer | ||
| 17 | } | ||
| 18 | |||
| 19 | Prop("fontSize") { view: T3TerminalView, fontSize: Double -> | ||
| 20 | view.fontSize = fontSize.toFloat() | ||
| 21 | } | ||
| 22 | |||
| 23 | Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String -> | ||
| 24 | view.appearanceScheme = appearanceScheme | ||
| 25 | } | ||
| 26 | |||
| 27 | Prop("themeConfig") { view: T3TerminalView, themeConfig: String -> | ||
| 28 | view.themeConfig = themeConfig | ||
| 29 | } | ||
| 30 | |||
| 31 | Prop("backgroundColor") { view: T3TerminalView, backgroundColor: String -> | ||
| 32 | view.backgroundColorHex = backgroundColor | ||
| 33 | } | ||
| 34 | |||
| 35 | Prop("foregroundColor") { view: T3TerminalView, foregroundColor: String -> | ||
| 36 | view.foregroundColorHex = foregroundColor | ||
| 37 | } | ||
| 38 | |||
| 39 | Prop("mutedForegroundColor") { view: T3TerminalView, mutedForegroundColor: String -> | ||
| 40 | view.mutedForegroundColorHex = mutedForegroundColor | ||
| 41 | } | ||
| 42 | |||
| 43 | Events("onInput", "onResize") | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt created+211| ... | @@ -0,0 +1,211 @@ | ||
| 1 | package expo.modules.t3terminal | ||
| 2 | |||
| 3 | import android.content.Context | ||
| 4 | import android.graphics.Color | ||
| 5 | import android.graphics.Typeface | ||
| 6 | import android.view.View | ||
| 7 | import android.view.ViewGroup | ||
| 8 | import android.view.inputmethod.InputMethodManager | ||
| 9 | import android.view.inputmethod.EditorInfo | ||
| 10 | import android.widget.EditText | ||
| 11 | import android.widget.LinearLayout | ||
| 12 | import android.widget.ScrollView | ||
| 13 | import android.widget.TextView | ||
| 14 | import androidx.core.widget.doAfterTextChanged | ||
| 15 | import expo.modules.kotlin.AppContext | ||
| 16 | import expo.modules.kotlin.views.ExpoView | ||
| 17 | import expo.modules.kotlin.viewevent.EventDispatcher | ||
| 18 | import kotlin.math.max | ||
| 19 | import kotlin.math.min | ||
| 20 | |||
| 21 | class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { | ||
| 22 | private val container = LinearLayout(context) | ||
| 23 | private val scrollView = ScrollView(context) | ||
| 24 | private val textView = TextView(context) | ||
| 25 | private val inputView = EditText(context) | ||
| 26 | private val onInput by EventDispatcher() | ||
| 27 | private val onResize by EventDispatcher() | ||
| 28 | private var lastWidth = 0 | ||
| 29 | private var lastHeight = 0 | ||
| 30 | private var clearingInput = false | ||
| 31 | private var backgroundColorValue = Color.parseColor("#24292E") | ||
| 32 | private var foregroundColorValue = Color.parseColor("#D1D5DA") | ||
| 33 | private var mutedForegroundColorValue = Color.parseColor("#959DA5") | ||
| 34 | |||
| 35 | var terminalKey: String = "" | ||
| 36 | set(value) { | ||
| 37 | field = value | ||
| 38 | contentDescription = "t3-terminal-$value" | ||
| 39 | } | ||
| 40 | |||
| 41 | var initialBuffer: String = "" | ||
| 42 | set(value) { | ||
| 43 | field = value | ||
| 44 | textView.text = value.ifEmpty { "$ " } | ||
| 45 | scrollView.post { | ||
| 46 | scrollView.fullScroll(View.FOCUS_DOWN) | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | var fontSize: Float = 10f | ||
| 51 | set(value) { | ||
| 52 | field = value | ||
| 53 | textView.textSize = value | ||
| 54 | inputView.textSize = max(value, 13f) | ||
| 55 | emitResize() | ||
| 56 | } | ||
| 57 | |||
| 58 | var appearanceScheme: String = "dark" | ||
| 59 | set(value) { | ||
| 60 | field = value | ||
| 61 | } | ||
| 62 | |||
| 63 | var themeConfig: String = "" | ||
| 64 | |||
| 65 | var backgroundColorHex: String = "#24292E" | ||
| 66 | set(value) { | ||
| 67 | field = value | ||
| 68 | backgroundColorValue = parseColor(value, backgroundColorValue) | ||
| 69 | applyTheme() | ||
| 70 | } | ||
| 71 | |||
| 72 | var foregroundColorHex: String = "#D1D5DA" | ||
| 73 | set(value) { | ||
| 74 | field = value | ||
| 75 | foregroundColorValue = parseColor(value, foregroundColorValue) | ||
| 76 | applyTheme() | ||
| 77 | } | ||
| 78 | |||
| 79 | var mutedForegroundColorHex: String = "#959DA5" | ||
| 80 | set(value) { | ||
| 81 | field = value | ||
| 82 | mutedForegroundColorValue = parseColor(value, mutedForegroundColorValue) | ||
| 83 | applyTheme() | ||
| 84 | } | ||
| 85 | |||
| 86 | init { | ||
| 87 | applyTheme() | ||
| 88 | container.orientation = LinearLayout.VERTICAL | ||
| 89 | textView.typeface = Typeface.MONOSPACE | ||
| 90 | textView.textSize = fontSize | ||
| 91 | textView.setPadding(8, 8, 8, 8) | ||
| 92 | textView.text = "$ " | ||
| 93 | |||
| 94 | inputView.setSingleLine(true) | ||
| 95 | inputView.setTextColor(Color.TRANSPARENT) | ||
| 96 | inputView.setHintTextColor(Color.TRANSPARENT) | ||
| 97 | inputView.setBackgroundColor(Color.TRANSPARENT) | ||
| 98 | inputView.typeface = Typeface.MONOSPACE | ||
| 99 | inputView.textSize = max(fontSize, 13f) | ||
| 100 | inputView.hint = "" | ||
| 101 | inputView.alpha = 0.02f | ||
| 102 | inputView.imeOptions = EditorInfo.IME_ACTION_SEND | ||
| 103 | inputView.setPadding(0, 0, 0, 0) | ||
| 104 | inputView.setOnFocusChangeListener { _, hasFocus -> | ||
| 105 | if (hasFocus) { | ||
| 106 | showKeyboard() | ||
| 107 | } | ||
| 108 | } | ||
| 109 | inputView.setOnEditorActionListener { view, actionId, _ -> | ||
| 110 | if (actionId != EditorInfo.IME_ACTION_SEND) return@setOnEditorActionListener false | ||
| 111 | onInput(mapOf("data" to "\n")) | ||
| 112 | true | ||
| 113 | } | ||
| 114 | inputView.setOnKeyListener { _, keyCode, event -> | ||
| 115 | if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false | ||
| 116 | when (keyCode) { | ||
| 117 | android.view.KeyEvent.KEYCODE_DEL -> { | ||
| 118 | onInput(mapOf("data" to "\u007F")) | ||
| 119 | true | ||
| 120 | } | ||
| 121 | else -> false | ||
| 122 | } | ||
| 123 | } | ||
| 124 | inputView.doAfterTextChanged { editable -> | ||
| 125 | if (clearingInput) return@doAfterTextChanged | ||
| 126 | val text = editable?.toString().orEmpty() | ||
| 127 | if (text.isEmpty()) return@doAfterTextChanged | ||
| 128 | onInput(mapOf("data" to text)) | ||
| 129 | clearingInput = true | ||
| 130 | inputView.text?.clear() | ||
| 131 | clearingInput = false | ||
| 132 | } | ||
| 133 | |||
| 134 | textView.setOnClickListener { requestKeyboardFocus() } | ||
| 135 | scrollView.setOnClickListener { requestKeyboardFocus() } | ||
| 136 | container.setOnClickListener { requestKeyboardFocus() } | ||
| 137 | isClickable = true | ||
| 138 | setOnClickListener { requestKeyboardFocus() } | ||
| 139 | |||
| 140 | scrollView.addView( | ||
| 141 | textView, | ||
| 142 | LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT), | ||
| 143 | ) | ||
| 144 | container.addView( | ||
| 145 | scrollView, | ||
| 146 | LinearLayout.LayoutParams( | ||
| 147 | ViewGroup.LayoutParams.MATCH_PARENT, | ||
| 148 | 0, | ||
| 149 | 1f, | ||
| 150 | ), | ||
| 151 | ) | ||
| 152 | container.addView( | ||
| 153 | inputView, | ||
| 154 | LinearLayout.LayoutParams(1, 1), | ||
| 155 | ) | ||
| 156 | addView( | ||
| 157 | container, | ||
| 158 | LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), | ||
| 159 | ) | ||
| 160 | |||
| 161 | post { | ||
| 162 | requestKeyboardFocus() | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { | ||
| 167 | super.onSizeChanged(width, height, oldWidth, oldHeight) | ||
| 168 | if (width == lastWidth && height == lastHeight) return | ||
| 169 | lastWidth = width | ||
| 170 | lastHeight = height | ||
| 171 | emitResize() | ||
| 172 | } | ||
| 173 | |||
| 174 | private fun emitResize() { | ||
| 175 | if (width <= 0 || height <= 0) return | ||
| 176 | val density = resources.displayMetrics.scaledDensity | ||
| 177 | val fontPx = max(fontSize * density, 1f) | ||
| 178 | val cols = max(20, min(400, (width / (fontPx * 0.62f)).toInt())) | ||
| 179 | val terminalHeight = max(height - inputView.height, 0) | ||
| 180 | val rows = max(5, min(200, (terminalHeight / (fontPx * 1.35f)).toInt())) | ||
| 181 | onResize(mapOf("cols" to cols, "rows" to rows)) | ||
| 182 | } | ||
| 183 | |||
| 184 | private fun requestKeyboardFocus() { | ||
| 185 | inputView.requestFocus() | ||
| 186 | showKeyboard() | ||
| 187 | } | ||
| 188 | |||
| 189 | private fun applyTheme() { | ||
| 190 | setBackgroundColor(backgroundColorValue) | ||
| 191 | container.setBackgroundColor(backgroundColorValue) | ||
| 192 | scrollView.setBackgroundColor(backgroundColorValue) | ||
| 193 | textView.setTextColor(foregroundColorValue) | ||
| 194 | textView.setBackgroundColor(backgroundColorValue) | ||
| 195 | inputView.setTextColor(Color.TRANSPARENT) | ||
| 196 | inputView.setHintTextColor(mutedForegroundColorValue) | ||
| 197 | inputView.setBackgroundColor(Color.TRANSPARENT) | ||
| 198 | } | ||
| 199 | |||
| 200 | private fun parseColor(value: String, fallback: Int): Int = | ||
| 201 | try { | ||
| 202 | Color.parseColor(value) | ||
| 203 | } catch (_: IllegalArgumentException) { | ||
| 204 | fallback | ||
| 205 | } | ||
| 206 | |||
| 207 | private fun showKeyboard() { | ||
| 208 | val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager | ||
| 209 | imm?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT) | ||
| 210 | } | ||
| 211 | } | ||
apps/mobile/modules/t3-terminal/expo-module.config.json created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | { | ||
| 2 | "platforms": ["apple", "android"], | ||
| 3 | "apple": { | ||
| 4 | "modules": ["T3TerminalModule"], | ||
| 5 | "podspecPath": "T3TerminalNative.podspec" | ||
| 6 | }, | ||
| 7 | "android": { | ||
| 8 | "modules": ["expo.modules.t3terminal.T3TerminalModule"] | ||
| 9 | } | ||
| 10 | } | ||
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import ExpoModulesCore | ||
| 2 | |||
| 3 | public class T3TerminalModule: Module { | ||
| 4 | public func definition() -> ModuleDefinition { | ||
| 5 | Name("T3TerminalSurface") | ||
| 6 | |||
| 7 | View(T3TerminalView.self) { | ||
| 8 | Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in | ||
| 9 | view.terminalKey = terminalKey | ||
| 10 | } | ||
| 11 | |||
| 12 | Prop("initialBuffer") { (view: T3TerminalView, initialBuffer: String) in | ||
| 13 | view.initialBuffer = initialBuffer | ||
| 14 | } | ||
| 15 | |||
| 16 | Prop("fontSize") { (view: T3TerminalView, fontSize: Double) in | ||
| 17 | view.fontSize = CGFloat(fontSize) | ||
| 18 | } | ||
| 19 | |||
| 20 | Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in | ||
| 21 | view.appearanceScheme = appearanceScheme | ||
| 22 | } | ||
| 23 | |||
| 24 | Prop("themeConfig") { (view: T3TerminalView, themeConfig: String) in | ||
| 25 | view.themeConfig = themeConfig | ||
| 26 | } | ||
| 27 | |||
| 28 | Prop("backgroundColor") { (view: T3TerminalView, backgroundColor: String) in | ||
| 29 | view.backgroundColorHex = backgroundColor | ||
| 30 | } | ||
| 31 | |||
| 32 | Prop("foregroundColor") { (view: T3TerminalView, foregroundColor: String) in | ||
| 33 | view.foregroundColorHex = foregroundColor | ||
| 34 | } | ||
| 35 | |||
| 36 | Prop("mutedForegroundColor") { (view: T3TerminalView, mutedForegroundColor: String) in | ||
| 37 | view.mutedForegroundColorHex = mutedForegroundColor | ||
| 38 | } | ||
| 39 | |||
| 40 | Events("onInput", "onResize") | ||
| 41 | } | ||
| 42 | } | ||
| 43 | } | ||
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift created+560| ... | @@ -0,0 +1,560 @@ | ||
| 1 | import ExpoModulesCore | ||
| 2 | import Foundation | ||
| 3 | import GhosttyKit | ||
| 4 | import QuartzCore | ||
| 5 | import UIKit | ||
| 6 | |||
| 7 | private enum GhosttyRuntime { | ||
| 8 | private static let lock = NSLock() | ||
| 9 | private static var initialized = false | ||
| 10 | |||
| 11 | static func ensureInitialized() -> Bool { | ||
| 12 | lock.lock() | ||
| 13 | defer { lock.unlock() } | ||
| 14 | |||
| 15 | if initialized { | ||
| 16 | return true | ||
| 17 | } | ||
| 18 | |||
| 19 | let result = ghostty_init(0, nil) | ||
| 20 | initialized = result == GHOSTTY_SUCCESS | ||
| 21 | return initialized | ||
| 22 | } | ||
| 23 | } | ||
| 24 | |||
| 25 | private final class TerminalInputField: UITextField { | ||
| 26 | var onDeleteBackward: (() -> Void)? | ||
| 27 | |||
| 28 | override func deleteBackward() { | ||
| 29 | onDeleteBackward?() | ||
| 30 | super.deleteBackward() | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | private enum TerminalAppearanceScheme: String { | ||
| 35 | case light | ||
| 36 | case dark | ||
| 37 | |||
| 38 | init(value: String) { | ||
| 39 | self = TerminalAppearanceScheme(rawValue: value) ?? .dark | ||
| 40 | } | ||
| 41 | |||
| 42 | var ghosttyColorScheme: ghostty_color_scheme_e { | ||
| 43 | switch self { | ||
| 44 | case .light: | ||
| 45 | return GHOSTTY_COLOR_SCHEME_LIGHT | ||
| 46 | case .dark: | ||
| 47 | return GHOSTTY_COLOR_SCHEME_DARK | ||
| 48 | } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | private extension UIColor { | ||
| 53 | convenience init(hexString: String) { | ||
| 54 | let sanitized = hexString.replacingOccurrences(of: "#", with: "") | ||
| 55 | let value = Int(sanitized, radix: 16) ?? 0 | ||
| 56 | self.init( | ||
| 57 | red: CGFloat((value >> 16) & 0xFF) / 255, | ||
| 58 | green: CGFloat((value >> 8) & 0xFF) / 255, | ||
| 59 | blue: CGFloat(value & 0xFF) / 255, | ||
| 60 | alpha: 1 | ||
| 61 | ) | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | public final class T3TerminalView: ExpoView, UITextFieldDelegate { | ||
| 66 | private static let minimumVerticalScrollStepPoints: CGFloat = 18 | ||
| 67 | private static let verticalScrollStepMultiplier: CGFloat = 1.15 | ||
| 68 | |||
| 69 | private let terminalViewport = UIView() | ||
| 70 | private let inputField = TerminalInputField() | ||
| 71 | private let focusTapGesture = UITapGestureRecognizer() | ||
| 72 | private let scrollPanGesture = UIPanGestureRecognizer() | ||
| 73 | private var lastViewportSize: CGSize = .zero | ||
| 74 | private var lastContentScale: CGFloat = 0 | ||
| 75 | private var lastReportedGrid: (cols: Int, rows: Int)? | ||
| 76 | private var lastAppliedBuffer = "" | ||
| 77 | private var pendingVerticalScrollPoints: CGFloat = 0 | ||
| 78 | private var app: ghostty_app_t? | ||
| 79 | private var surface: ghostty_surface_t? | ||
| 80 | private var isCreatingSurface = false | ||
| 81 | private var surfaceCreationFailed = false | ||
| 82 | private var appearance = TerminalAppearanceScheme.dark | ||
| 83 | private var backgroundColorValue = UIColor(hexString: "#24292e") | ||
| 84 | |||
| 85 | let onInput = EventDispatcher() | ||
| 86 | let onResize = EventDispatcher() | ||
| 87 | |||
| 88 | var terminalKey: String = "" { | ||
| 89 | didSet { | ||
| 90 | accessibilityIdentifier = "t3-terminal-\(terminalKey)" | ||
| 91 | if oldValue != terminalKey { | ||
| 92 | resetSurface() | ||
| 93 | } | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | var initialBuffer: String = "" { | ||
| 98 | didSet { | ||
| 99 | applyRemoteBuffer(initialBuffer) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | var fontSize: CGFloat = 10 { | ||
| 104 | didSet { | ||
| 105 | guard oldValue != fontSize else { return } | ||
| 106 | inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) | ||
| 107 | refreshSurface() | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { | ||
| 112 | didSet { | ||
| 113 | guard oldValue != appearanceScheme else { return } | ||
| 114 | appearance = TerminalAppearanceScheme(value: appearanceScheme) | ||
| 115 | refreshSurface() | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | var themeConfig: String = "" { | ||
| 120 | didSet { | ||
| 121 | guard oldValue != themeConfig else { return } | ||
| 122 | refreshSurface() | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | var backgroundColorHex: String = "#24292e" { | ||
| 127 | didSet { | ||
| 128 | backgroundColorValue = UIColor(hexString: backgroundColorHex) | ||
| 129 | applyTheme() | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | var foregroundColorHex: String = "#d1d5da" | ||
| 134 | var mutedForegroundColorHex: String = "#959da5" | ||
| 135 | |||
| 136 | public required init(appContext: AppContext? = nil) { | ||
| 137 | super.init(appContext: appContext) | ||
| 138 | |||
| 139 | applyTheme() | ||
| 140 | clipsToBounds = true | ||
| 141 | contentScaleFactor = UIScreen.main.scale | ||
| 142 | |||
| 143 | terminalViewport.clipsToBounds = true | ||
| 144 | terminalViewport.contentScaleFactor = contentScaleFactor | ||
| 145 | terminalViewport.translatesAutoresizingMaskIntoConstraints = false | ||
| 146 | terminalViewport.isUserInteractionEnabled = true | ||
| 147 | |||
| 148 | inputField.delegate = self | ||
| 149 | inputField.backgroundColor = UIColor.clear | ||
| 150 | inputField.textColor = UIColor.clear | ||
| 151 | inputField.tintColor = UIColor.clear | ||
| 152 | inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) | ||
| 153 | inputField.placeholder = "" | ||
| 154 | inputField.autocorrectionType = .no | ||
| 155 | inputField.autocapitalizationType = .none | ||
| 156 | inputField.spellCheckingType = .no | ||
| 157 | inputField.smartDashesType = .no | ||
| 158 | inputField.smartQuotesType = .no | ||
| 159 | inputField.returnKeyType = .send | ||
| 160 | inputField.keyboardType = .asciiCapable | ||
| 161 | inputField.enablesReturnKeyAutomatically = false | ||
| 162 | inputField.translatesAutoresizingMaskIntoConstraints = false | ||
| 163 | inputField.alpha = 0.02 | ||
| 164 | inputField.isAccessibilityElement = false | ||
| 165 | inputField.accessibilityElementsHidden = true | ||
| 166 | inputField.addTarget(self, action: #selector(handleInputEditingDidBegin), for: .editingDidBegin) | ||
| 167 | inputField.onDeleteBackward = { [weak self] in | ||
| 168 | self?.emitInput("\u{7F}") | ||
| 169 | } | ||
| 170 | |||
| 171 | focusTapGesture.addTarget(self, action: #selector(handleViewportTap)) | ||
| 172 | terminalViewport.addGestureRecognizer(focusTapGesture) | ||
| 173 | scrollPanGesture.addTarget(self, action: #selector(handleViewportPan(_:))) | ||
| 174 | scrollPanGesture.maximumNumberOfTouches = 1 | ||
| 175 | scrollPanGesture.cancelsTouchesInView = false | ||
| 176 | terminalViewport.addGestureRecognizer(scrollPanGesture) | ||
| 177 | |||
| 178 | addSubview(terminalViewport) | ||
| 179 | addSubview(inputField) | ||
| 180 | |||
| 181 | NSLayoutConstraint.activate([ | ||
| 182 | terminalViewport.leadingAnchor.constraint(equalTo: leadingAnchor), | ||
| 183 | terminalViewport.trailingAnchor.constraint(equalTo: trailingAnchor), | ||
| 184 | terminalViewport.topAnchor.constraint(equalTo: topAnchor), | ||
| 185 | terminalViewport.bottomAnchor.constraint(equalTo: bottomAnchor), | ||
| 186 | |||
| 187 | inputField.trailingAnchor.constraint(equalTo: trailingAnchor), | ||
| 188 | inputField.topAnchor.constraint(equalTo: bottomAnchor, constant: 8), | ||
| 189 | inputField.widthAnchor.constraint(equalToConstant: 1), | ||
| 190 | inputField.heightAnchor.constraint(equalToConstant: 1), | ||
| 191 | ]) | ||
| 192 | } | ||
| 193 | |||
| 194 | deinit { | ||
| 195 | destroySurface() | ||
| 196 | } | ||
| 197 | |||
| 198 | public override func layoutSubviews() { | ||
| 199 | super.layoutSubviews() | ||
| 200 | updateContentScale() | ||
| 201 | |||
| 202 | let viewportSize = terminalViewport.bounds.size | ||
| 203 | if surface == nil { | ||
| 204 | createSurfaceIfPossible() | ||
| 205 | } | ||
| 206 | |||
| 207 | guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else { | ||
| 208 | return | ||
| 209 | } | ||
| 210 | |||
| 211 | lastViewportSize = viewportSize | ||
| 212 | lastContentScale = contentScaleFactor | ||
| 213 | resizeSurface() | ||
| 214 | } | ||
| 215 | |||
| 216 | public override func didMoveToWindow() { | ||
| 217 | super.didMoveToWindow() | ||
| 218 | |||
| 219 | guard window != nil else { return } | ||
| 220 | DispatchQueue.main.async { [weak self] in | ||
| 221 | self?.requestKeyboardFocus() | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { | ||
| 226 | if !string.isEmpty { | ||
| 227 | emitInput(string) | ||
| 228 | return false | ||
| 229 | } | ||
| 230 | |||
| 231 | return false | ||
| 232 | } | ||
| 233 | |||
| 234 | public func textFieldShouldReturn(_ textField: UITextField) -> Bool { | ||
| 235 | emitInput("\n") | ||
| 236 | textField.text = "" | ||
| 237 | return false | ||
| 238 | } | ||
| 239 | |||
| 240 | @objc | ||
| 241 | private func handleViewportTap() { | ||
| 242 | requestKeyboardFocus() | ||
| 243 | } | ||
| 244 | |||
| 245 | @objc | ||
| 246 | private func handleViewportPan(_ gesture: UIPanGestureRecognizer) { | ||
| 247 | guard let surface else { return } | ||
| 248 | |||
| 249 | let location = gesture.location(in: terminalViewport) | ||
| 250 | ghostty_surface_mouse_pos( | ||
| 251 | surface, | ||
| 252 | Double(location.x * contentScaleFactor), | ||
| 253 | Double(location.y * contentScaleFactor), | ||
| 254 | GHOSTTY_MODS_NONE | ||
| 255 | ) | ||
| 256 | |||
| 257 | switch gesture.state { | ||
| 258 | case .began: | ||
| 259 | pendingVerticalScrollPoints = 0 | ||
| 260 | gesture.setTranslation(.zero, in: terminalViewport) | ||
| 261 | case .changed: | ||
| 262 | let translation = gesture.translation(in: terminalViewport) | ||
| 263 | let stepSize = max( | ||
| 264 | fontSize * Self.verticalScrollStepMultiplier, | ||
| 265 | Self.minimumVerticalScrollStepPoints | ||
| 266 | ) | ||
| 267 | let totalVerticalPoints = pendingVerticalScrollPoints + translation.y | ||
| 268 | let verticalSteps = Int(totalVerticalPoints / stepSize) | ||
| 269 | pendingVerticalScrollPoints = totalVerticalPoints - (CGFloat(verticalSteps) * stepSize) | ||
| 270 | |||
| 271 | guard verticalSteps != 0 else { | ||
| 272 | gesture.setTranslation(.zero, in: terminalViewport) | ||
| 273 | return | ||
| 274 | } | ||
| 275 | |||
| 276 | ghostty_surface_mouse_scroll(surface, 0, Double(verticalSteps), 0) | ||
| 277 | redrawSurface() | ||
| 278 | gesture.setTranslation(.zero, in: terminalViewport) | ||
| 279 | default: | ||
| 280 | pendingVerticalScrollPoints = 0 | ||
| 281 | gesture.setTranslation(.zero, in: terminalViewport) | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | @objc | ||
| 286 | private func handleInputEditingDidBegin() { | ||
| 287 | textInputModeDidChange() | ||
| 288 | } | ||
| 289 | |||
| 290 | private func createSurfaceIfPossible() { | ||
| 291 | guard surface == nil, app == nil, !isCreatingSurface, !surfaceCreationFailed else { return } | ||
| 292 | guard terminalViewport.bounds.width > 0, terminalViewport.bounds.height > 0 else { return } | ||
| 293 | guard GhosttyRuntime.ensureInitialized() else { | ||
| 294 | surfaceCreationFailed = true | ||
| 295 | return | ||
| 296 | } | ||
| 297 | |||
| 298 | isCreatingSurface = true | ||
| 299 | defer { isCreatingSurface = false } | ||
| 300 | |||
| 301 | var runtimeConfig = ghostty_runtime_config_s( | ||
| 302 | userdata: Unmanaged.passUnretained(self).toOpaque(), | ||
| 303 | supports_selection_clipboard: false, | ||
| 304 | wakeup_cb: { _ in }, | ||
| 305 | action_cb: { _, _, _ in false }, | ||
| 306 | read_clipboard_cb: { _, _, _ in false }, | ||
| 307 | confirm_read_clipboard_cb: { _, _, _, _ in }, | ||
| 308 | write_clipboard_cb: { _, _, _, _, _ in }, | ||
| 309 | close_surface_cb: { _, _ in } | ||
| 310 | ) | ||
| 311 | |||
| 312 | guard let config = ghostty_config_new() else { | ||
| 313 | surfaceCreationFailed = true | ||
| 314 | return | ||
| 315 | } | ||
| 316 | loadThemeConfig(into: config) | ||
| 317 | ghostty_config_finalize(config) | ||
| 318 | defer { ghostty_config_free(config) } | ||
| 319 | |||
| 320 | guard let createdApp = ghostty_app_new(&runtimeConfig, config) else { | ||
| 321 | surfaceCreationFailed = true | ||
| 322 | return | ||
| 323 | } | ||
| 324 | |||
| 325 | var surfaceConfig = ghostty_surface_config_new() | ||
| 326 | surfaceConfig.platform_tag = GHOSTTY_PLATFORM_IOS | ||
| 327 | surfaceConfig.platform.ios.uiview = Unmanaged.passUnretained(terminalViewport).toOpaque() | ||
| 328 | surfaceConfig.userdata = Unmanaged.passUnretained(self).toOpaque() | ||
| 329 | surfaceConfig.scale_factor = Double(contentScaleFactor) | ||
| 330 | surfaceConfig.font_size = Float(fontSize) | ||
| 331 | surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW | ||
| 332 | surfaceConfig.use_custom_io = true | ||
| 333 | |||
| 334 | guard let createdSurface = ghostty_surface_new(createdApp, &surfaceConfig) else { | ||
| 335 | ghostty_app_free(createdApp) | ||
| 336 | surfaceCreationFailed = true | ||
| 337 | return | ||
| 338 | } | ||
| 339 | |||
| 340 | app = createdApp | ||
| 341 | surface = createdSurface | ||
| 342 | ghostty_app_set_color_scheme(createdApp, appearance.ghosttyColorScheme) | ||
| 343 | ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme) | ||
| 344 | setupWriteCallback() | ||
| 345 | resizeSurface() | ||
| 346 | feedBuffer(initialBuffer) | ||
| 347 | } | ||
| 348 | |||
| 349 | private func resetSurface() { | ||
| 350 | destroySurface() | ||
| 351 | lastAppliedBuffer = "" | ||
| 352 | lastViewportSize = .zero | ||
| 353 | lastContentScale = 0 | ||
| 354 | lastReportedGrid = nil | ||
| 355 | surfaceCreationFailed = false | ||
| 356 | setNeedsLayout() | ||
| 357 | } | ||
| 358 | |||
| 359 | private func refreshSurface() { | ||
| 360 | resetSurface() | ||
| 361 | createSurfaceIfPossible() | ||
| 362 | } | ||
| 363 | |||
| 364 | private func destroySurface() { | ||
| 365 | if let surface { | ||
| 366 | ghostty_surface_set_write_callback(surface, nil, nil) | ||
| 367 | ghostty_surface_free(surface) | ||
| 368 | } | ||
| 369 | if let app { | ||
| 370 | ghostty_app_free(app) | ||
| 371 | } | ||
| 372 | surface = nil | ||
| 373 | app = nil | ||
| 374 | } | ||
| 375 | |||
| 376 | private func applyRemoteBuffer(_ buffer: String) { | ||
| 377 | guard surface != nil else { | ||
| 378 | createSurfaceIfPossible() | ||
| 379 | return | ||
| 380 | } | ||
| 381 | |||
| 382 | if buffer.hasPrefix(lastAppliedBuffer) { | ||
| 383 | let suffix = String(buffer.dropFirst(lastAppliedBuffer.count)) | ||
| 384 | feedData(Data(suffix.utf8)) | ||
| 385 | lastAppliedBuffer = buffer | ||
| 386 | return | ||
| 387 | } | ||
| 388 | |||
| 389 | resetSurface() | ||
| 390 | createSurfaceIfPossible() | ||
| 391 | } | ||
| 392 | |||
| 393 | private func feedBuffer(_ buffer: String) { | ||
| 394 | guard !buffer.isEmpty else { return } | ||
| 395 | feedData(Data(buffer.utf8)) | ||
| 396 | lastAppliedBuffer = buffer | ||
| 397 | } | ||
| 398 | |||
| 399 | private func feedData(_ data: Data) { | ||
| 400 | guard let surface, !data.isEmpty else { return } | ||
| 401 | |||
| 402 | data.withUnsafeBytes { buffer in | ||
| 403 | guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { | ||
| 404 | return | ||
| 405 | } | ||
| 406 | ghostty_surface_feed_data(surface, pointer, buffer.count) | ||
| 407 | } | ||
| 408 | |||
| 409 | redrawSurface() | ||
| 410 | } | ||
| 411 | |||
| 412 | private func setupWriteCallback() { | ||
| 413 | guard let surface else { return } | ||
| 414 | |||
| 415 | let userdata = Unmanaged.passUnretained(self).toOpaque() | ||
| 416 | ghostty_surface_set_write_callback(surface, { userdata, data, len in | ||
| 417 | guard let userdata, let data, len > 0 else { return } | ||
| 418 | let view = Unmanaged<T3TerminalView>.fromOpaque(userdata).takeUnretainedValue() | ||
| 419 | let bytes = Data(bytes: data, count: len) | ||
| 420 | guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return } | ||
| 421 | |||
| 422 | DispatchQueue.main.async { | ||
| 423 | view.onInput(["data": input]) | ||
| 424 | } | ||
| 425 | }, userdata) | ||
| 426 | } | ||
| 427 | |||
| 428 | private func resizeSurface() { | ||
| 429 | guard let surface else { | ||
| 430 | emitEstimatedResize() | ||
| 431 | return | ||
| 432 | } | ||
| 433 | |||
| 434 | let scale = contentScaleFactor | ||
| 435 | let width = UInt32(max(floor(terminalViewport.bounds.width * scale), 1)) | ||
| 436 | let height = UInt32(max(floor(terminalViewport.bounds.height * scale), 1)) | ||
| 437 | |||
| 438 | terminalViewport.contentScaleFactor = scale | ||
| 439 | ghostty_surface_set_content_scale(surface, Double(scale), Double(scale)) | ||
| 440 | ghostty_surface_set_size(surface, width, height) | ||
| 441 | ghostty_surface_set_occlusion(surface, window != nil) | ||
| 442 | configureIOSurfaceLayers() | ||
| 443 | redrawSurface() | ||
| 444 | emitGhosttyResize() | ||
| 445 | } | ||
| 446 | |||
| 447 | private func redrawSurface() { | ||
| 448 | guard let surface else { return } | ||
| 449 | ghostty_surface_refresh(surface) | ||
| 450 | ghostty_surface_draw(surface) | ||
| 451 | markIOSurfaceLayersForDisplay() | ||
| 452 | emitGhosttyResize() | ||
| 453 | } | ||
| 454 | |||
| 455 | private func emitGhosttyResize() { | ||
| 456 | guard let surface else { | ||
| 457 | emitEstimatedResize() | ||
| 458 | return | ||
| 459 | } | ||
| 460 | |||
| 461 | let size = ghostty_surface_size(surface) | ||
| 462 | let cols = max(1, Int(size.columns)) | ||
| 463 | let rows = max(1, Int(size.rows)) | ||
| 464 | emitResize(cols: cols, rows: rows) | ||
| 465 | } | ||
| 466 | |||
| 467 | private func emitEstimatedResize() { | ||
| 468 | guard bounds.width > 0, bounds.height > 0 else { return } | ||
| 469 | |||
| 470 | let cellWidth = max(fontSize * 0.62, 1) | ||
| 471 | let cellHeight = max(fontSize * 1.35, 1) | ||
| 472 | let cols = max(20, min(400, Int(bounds.width / cellWidth))) | ||
| 473 | let terminalHeight = max(bounds.height, 0) | ||
| 474 | let rows = max(5, min(200, Int(terminalHeight / cellHeight))) | ||
| 475 | emitResize(cols: cols, rows: rows) | ||
| 476 | } | ||
| 477 | |||
| 478 | private func emitResize(cols: Int, rows: Int) { | ||
| 479 | guard lastReportedGrid?.cols != cols || lastReportedGrid?.rows != rows else { | ||
| 480 | return | ||
| 481 | } | ||
| 482 | |||
| 483 | lastReportedGrid = (cols, rows) | ||
| 484 | onResize([ | ||
| 485 | "cols": cols, | ||
| 486 | "rows": rows, | ||
| 487 | ]) | ||
| 488 | } | ||
| 489 | |||
| 490 | private func updateContentScale() { | ||
| 491 | let scale = window?.screen.scale ?? UIScreen.main.scale | ||
| 492 | if contentScaleFactor != scale { | ||
| 493 | contentScaleFactor = scale | ||
| 494 | } | ||
| 495 | } | ||
| 496 | |||
| 497 | private func requestKeyboardFocus() { | ||
| 498 | guard window != nil else { return } | ||
| 499 | inputField.becomeFirstResponder() | ||
| 500 | textInputModeDidChange() | ||
| 501 | } | ||
| 502 | |||
| 503 | private func emitInput(_ data: String) { | ||
| 504 | guard !data.isEmpty else { return } | ||
| 505 | onInput(["data": data]) | ||
| 506 | } | ||
| 507 | |||
| 508 | private func textInputModeDidChange() { | ||
| 509 | guard let app else { return } | ||
| 510 | ghostty_app_keyboard_changed(app) | ||
| 511 | } | ||
| 512 | |||
| 513 | private func configureIOSurfaceLayers() { | ||
| 514 | let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size) | ||
| 515 | CATransaction.begin() | ||
| 516 | CATransaction.setDisableActions(true) | ||
| 517 | terminalViewport.layer.sublayers?.forEach { sublayer in | ||
| 518 | sublayer.frame = targetBounds | ||
| 519 | sublayer.contentsScale = contentScaleFactor | ||
| 520 | } | ||
| 521 | CATransaction.commit() | ||
| 522 | } | ||
| 523 | |||
| 524 | private func markIOSurfaceLayersForDisplay() { | ||
| 525 | terminalViewport.layer.setNeedsDisplay() | ||
| 526 | terminalViewport.layer.sublayers?.forEach { layer in | ||
| 527 | layer.setNeedsDisplay() | ||
| 528 | } | ||
| 529 | } | ||
| 530 | |||
| 531 | private func applyTheme() { | ||
| 532 | backgroundColor = backgroundColorValue | ||
| 533 | terminalViewport.backgroundColor = backgroundColorValue | ||
| 534 | } | ||
| 535 | |||
| 536 | private func loadThemeConfig(into config: ghostty_config_t) { | ||
| 537 | guard let path = writeThemeConfigFile() else { return } | ||
| 538 | path.withCString { cString in | ||
| 539 | ghostty_config_load_file(config, cString) | ||
| 540 | } | ||
| 541 | } | ||
| 542 | |||
| 543 | private func writeThemeConfigFile() -> String? { | ||
| 544 | guard !themeConfig.isEmpty else { return nil } | ||
| 545 | let configContents = themeConfig | ||
| 546 | let url = URL(fileURLWithPath: NSTemporaryDirectory()) | ||
| 547 | .appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty") | ||
| 548 | |||
| 549 | do { | ||
| 550 | if let existing = try? String(contentsOf: url, encoding: .utf8), existing == configContents { | ||
| 551 | return url.path | ||
| 552 | } | ||
| 553 | |||
| 554 | try configContents.write(to: url, atomically: true, encoding: .utf8) | ||
| 555 | return url.path | ||
| 556 | } catch { | ||
| 557 | return nil | ||
| 558 | } | ||
| 559 | } | ||
| 560 | } | ||
apps/mobile/modules/t3-terminal/package.json created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/mobile-terminal-native", | ||
| 3 | "version": "0.0.0", | ||
| 4 | "private": true, | ||
| 5 | "expo-module": { | ||
| 6 | "ios": { | ||
| 7 | "modules": [ | ||
| 8 | "T3TerminalModule" | ||
| 9 | ] | ||
| 10 | }, | ||
| 11 | "android": { | ||
| 12 | "modules": [ | ||
| 13 | "expo.modules.t3terminal.T3TerminalModule" | ||
| 14 | ] | ||
| 15 | } | ||
| 16 | } | ||
| 17 | } | ||
apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | #!/usr/bin/env bash | ||
| 2 | |||
| 3 | set -euo pipefail | ||
| 4 | |||
| 5 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| 6 | MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" | ||
| 7 | VENDOR_DIR="${MODULE_DIR}/Vendor/libghostty" | ||
| 8 | |||
| 9 | GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/ghostty}" | ||
| 10 | GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" | ||
| 11 | GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" | ||
| 12 | |||
| 13 | log() { | ||
| 14 | printf '[libghostty-ios16] %s\n' "$*" | ||
| 15 | } | ||
| 16 | |||
| 17 | die() { | ||
| 18 | printf '[libghostty-ios16] error: %s\n' "$*" >&2 | ||
| 19 | exit 1 | ||
| 20 | } | ||
| 21 | |||
| 22 | require_cmd() { | ||
| 23 | command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" | ||
| 24 | } | ||
| 25 | |||
| 26 | ensure_zig() { | ||
| 27 | if [[ -n "${GHOSTTY_ZIG}" ]]; then | ||
| 28 | [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}" | ||
| 29 | return | ||
| 30 | fi | ||
| 31 | |||
| 32 | if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then | ||
| 33 | GHOSTTY_ZIG="$(command -v zig)" | ||
| 34 | return | ||
| 35 | fi | ||
| 36 | |||
| 37 | local cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}" | ||
| 38 | local archive_arch | ||
| 39 | archive_arch="$(uname -m)" | ||
| 40 | case "${archive_arch}" in | ||
| 41 | arm64) archive_arch="aarch64" ;; | ||
| 42 | x86_64) archive_arch="x86_64" ;; | ||
| 43 | *) die "unsupported macOS architecture for Zig download: ${archive_arch}" ;; | ||
| 44 | esac | ||
| 45 | |||
| 46 | GHOSTTY_ZIG="${cache_dir}/zig" | ||
| 47 | if [[ -x "${GHOSTTY_ZIG}" ]]; then | ||
| 48 | return | ||
| 49 | fi | ||
| 50 | |||
| 51 | require_cmd curl | ||
| 52 | require_cmd tar | ||
| 53 | mkdir -p "${cache_dir}" | ||
| 54 | log "downloading Zig ${GHOSTTY_ZIG_VERSION}" | ||
| 55 | curl -fsSL "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${archive_arch}-macos-${GHOSTTY_ZIG_VERSION}.tar.xz" \ | ||
| 56 | | tar -xJ --strip-components=1 -C "${cache_dir}" | ||
| 57 | } | ||
| 58 | |||
| 59 | require_cmd git | ||
| 60 | require_cmd xcodebuild | ||
| 61 | require_cmd xcrun | ||
| 62 | require_cmd rsync | ||
| 63 | ensure_zig | ||
| 64 | |||
| 65 | ghostty_ref="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)" | ||
| 66 | log "using Ghostty source: ${GHOSTTY_SOURCE_DIR} @ ${ghostty_ref}" | ||
| 67 | log "using Zig: ${GHOSTTY_ZIG} ($("${GHOSTTY_ZIG}" version))" | ||
| 68 | log "building GhosttyKit.xcframework" | ||
| 69 | |||
| 70 | ( | ||
| 71 | cd "${GHOSTTY_SOURCE_DIR}" | ||
| 72 | PATH="$(dirname "${GHOSTTY_ZIG}"):${PATH}" "${GHOSTTY_ZIG}" build \ | ||
| 73 | -Dapp-runtime=none \ | ||
| 74 | -Demit-xcframework=true \ | ||
| 75 | -Demit-macos-app=false \ | ||
| 76 | -Demit-exe=false \ | ||
| 77 | -Demit-docs=false \ | ||
| 78 | -Demit-webdata=false \ | ||
| 79 | -Demit-helpgen=false \ | ||
| 80 | -Demit-terminfo=false \ | ||
| 81 | -Demit-termcap=false \ | ||
| 82 | -Demit-themes=false \ | ||
| 83 | -Doptimize=ReleaseFast \ | ||
| 84 | -Dstrip \ | ||
| 85 | -Dxcframework-target=universal | ||
| 86 | ) | ||
| 87 | |||
| 88 | xcframework="${GHOSTTY_SOURCE_DIR}/macos/GhosttyKit.xcframework" | ||
| 89 | ios_archive="${xcframework}/ios-arm64/libghostty-fat.a" | ||
| 90 | sim_archive="${xcframework}/ios-arm64-simulator/libghostty-fat.a" | ||
| 91 | [[ -f "${ios_archive}" ]] || die "missing built iOS archive: ${ios_archive}" | ||
| 92 | [[ -f "${sim_archive}" ]] || die "missing built iOS simulator archive: ${sim_archive}" | ||
| 93 | |||
| 94 | log "stripping iOS archives" | ||
| 95 | xcrun strip -S -x "${ios_archive}" | ||
| 96 | xcrun strip -S -x "${sim_archive}" | ||
| 97 | |||
| 98 | log "copying iOS archives into ${VENDOR_DIR}/GhosttyKit.xcframework" | ||
| 99 | cp "${ios_archive}" "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a" | ||
| 100 | cp "${sim_archive}" "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a" | ||
| 101 | rsync -a --delete "${xcframework}/ios-arm64/Headers/" \ | ||
| 102 | "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64/Headers/" | ||
| 103 | rsync -a --delete "${xcframework}/ios-arm64-simulator/Headers/" \ | ||
| 104 | "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64-simulator/Headers/" | ||
| 105 | |||
| 106 | log "done" | ||
apps/mobile/package.json created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/mobile", | ||
| 3 | "version": "0.0.0", | ||
| 4 | "private": true, | ||
| 5 | "main": "index.ts", | ||
| 6 | "scripts": { | ||
| 7 | "dev": "expo start --clear", | ||
| 8 | "dev:client": "APP_VARIANT=development expo start --dev-client --clear", | ||
| 9 | "start": "expo start", | ||
| 10 | "start:dev": "APP_VARIANT=development expo start", | ||
| 11 | "start:preview": "APP_VARIANT=preview expo start", | ||
| 12 | "start:prod": "APP_VARIANT=production expo start", | ||
| 13 | "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", | ||
| 14 | "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", | ||
| 15 | "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", | ||
| 16 | "android:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", | ||
| 17 | "eas:android:dev": "eas build --profile development -p android", | ||
| 18 | "eas:android:preview": "eas build --profile preview -p android", | ||
| 19 | "eas:android:prod": "eas build --profile production -p android", | ||
| 20 | "ios": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", | ||
| 21 | "ios:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", | ||
| 22 | "ios:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", | ||
| 23 | "ios:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", | ||
| 24 | "eas:ios:dev": "eas build --profile development -p ios", | ||
| 25 | "eas:ios:preview": "eas build --profile preview -p ios", | ||
| 26 | "eas:ios:prod": "eas build --profile production -p ios", | ||
| 27 | "eas:dev": "eas build --profile development", | ||
| 28 | "eas:preview": "eas build --profile preview", | ||
| 29 | "eas:prod": "eas build --profile production", | ||
| 30 | "config:dev": "APP_VARIANT=development expo config", | ||
| 31 | "config:preview": "APP_VARIANT=preview expo config", | ||
| 32 | "config:prod": "APP_VARIANT=production expo config", | ||
| 33 | "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review", | ||
| 34 | "test": "vitest run", | ||
| 35 | "typecheck": "tsc --noEmit" | ||
| 36 | }, | ||
| 37 | "dependencies": { | ||
| 38 | "@callstack/liquid-glass": "^0.7.1", | ||
| 39 | "@effect/atom-react": "catalog:", | ||
| 40 | "@expo-google-fonts/dm-sans": "^0.4.2", | ||
| 41 | "@expo/ui": "~56.0.8", | ||
| 42 | "@legendapp/list": "3.0.0-beta.44", | ||
| 43 | "@pierre/diffs": "catalog:", | ||
| 44 | "@react-native-menu/menu": "^2.0.0", | ||
| 45 | "@shikijs/core": "3.23.0", | ||
| 46 | "@shikijs/engine-javascript": "3.23.0", | ||
| 47 | "@shikijs/langs": "3.23.0", | ||
| 48 | "@shikijs/themes": "3.23.0", | ||
| 49 | "@t3tools/client-runtime": "workspace:*", | ||
| 50 | "@t3tools/contracts": "workspace:*", | ||
| 51 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", | ||
| 52 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", | ||
| 53 | "@t3tools/shared": "workspace:*", | ||
| 54 | "clsx": "^2.1.1", | ||
| 55 | "diff": "8.0.3", | ||
| 56 | "effect": "catalog:", | ||
| 57 | "expo": "^56.0.0", | ||
| 58 | "expo-build-properties": "~56.0.15", | ||
| 59 | "expo-camera": "~56.0.7", | ||
| 60 | "expo-clipboard": "~56.0.3", | ||
| 61 | "expo-constants": "~56.0.16", | ||
| 62 | "expo-crypto": "~56.0.4", | ||
| 63 | "expo-dev-client": "~56.0.16", | ||
| 64 | "expo-file-system": "~56.0.7", | ||
| 65 | "expo-font": "~56.0.5", | ||
| 66 | "expo-glass-effect": "~56.0.4", | ||
| 67 | "expo-haptics": "~56.0.3", | ||
| 68 | "expo-image-picker": "~56.0.14", | ||
| 69 | "expo-linking": "~56.0.12", | ||
| 70 | "expo-paste-input": "^0.1.15", | ||
| 71 | "expo-router": "~56.2.7", | ||
| 72 | "expo-secure-store": "~56.0.4", | ||
| 73 | "expo-splash-screen": "~56.0.10", | ||
| 74 | "expo-symbols": "~56.0.5", | ||
| 75 | "expo-updates": "~56.0.17", | ||
| 76 | "punycode": "^2.3.1", | ||
| 77 | "react": "19.2.3", | ||
| 78 | "react-dom": "19.2.3", | ||
| 79 | "react-native": "0.85.3", | ||
| 80 | "react-native-gesture-handler": "~2.31.1", | ||
| 81 | "react-native-image-viewing": "^0.2.2", | ||
| 82 | "react-native-keyboard-controller": "1.21.6", | ||
| 83 | "react-native-nitro-markdown": "^0.5.0", | ||
| 84 | "react-native-nitro-modules": "^0.35.4", | ||
| 85 | "react-native-reanimated": "4.3.1", | ||
| 86 | "react-native-safe-area-context": "~5.7.0", | ||
| 87 | "react-native-screens": "4.25.2", | ||
| 88 | "react-native-shiki-engine": "^0.3.9", | ||
| 89 | "react-native-svg": "15.15.4", | ||
| 90 | "react-native-worklets": "0.8.3", | ||
| 91 | "shiki": "3.23.0", | ||
| 92 | "tailwind-merge": "^3.5.0", | ||
| 93 | "uniwind": "^1.6.2" | ||
| 94 | }, | ||
| 95 | "devDependencies": { | ||
| 96 | "@types/react": "~19.2.0", | ||
| 97 | "babel-preset-expo": "~56.0.0", | ||
| 98 | "tailwindcss": "^4.0.0", | ||
| 99 | "typescript": "catalog:" | ||
| 100 | }, | ||
| 101 | "overrides": { | ||
| 102 | "react-native-nitro-markdown": "file:deps/react-native-nitro-markdown-0.5.0.tgz" | ||
| 103 | } | ||
| 104 | } | ||
apps/mobile/plugins/withAndroidCleartextTraffic.cjs created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | const { withAndroidManifest } = require("expo/config-plugins"); | ||
| 2 | |||
| 3 | module.exports = function withAndroidCleartextTraffic(config) { | ||
| 4 | return withAndroidManifest(config, (nextConfig) => { | ||
| 5 | const application = nextConfig.modResults.manifest.application?.[0]; | ||
| 6 | |||
| 7 | if (application == null) { | ||
| 8 | throw new Error( | ||
| 9 | "AndroidManifest.xml is missing the application element required for cleartext traffic configuration.", | ||
| 10 | ); | ||
| 11 | } | ||
| 12 | |||
| 13 | application.$ ??= {}; | ||
| 14 | application.$["android:usesCleartextTraffic"] = "true"; | ||
| 15 | |||
| 16 | return nextConfig; | ||
| 17 | }); | ||
| 18 | }; | ||
apps/mobile/src/app/+not-found.tsx created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import { Link } from "expo-router"; | ||
| 2 | import { Pressable, ScrollView, StyleSheet } from "react-native"; | ||
| 3 | import { useResolveClassNames } from "uniwind"; | ||
| 4 | |||
| 5 | import { AppText as Text } from "../components/AppText"; | ||
| 6 | |||
| 7 | export default function NotFoundRoute() { | ||
| 8 | const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen")); | ||
| 9 | const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary")); | ||
| 10 | |||
| 11 | return ( | ||
| 12 | <ScrollView | ||
| 13 | contentInsetAdjustmentBehavior="automatic" | ||
| 14 | contentContainerStyle={{ | ||
| 15 | flexGrow: 1, | ||
| 16 | alignItems: "center", | ||
| 17 | justifyContent: "center", | ||
| 18 | gap: 16, | ||
| 19 | paddingHorizontal: 24, | ||
| 20 | paddingVertical: 32, | ||
| 21 | }} | ||
| 22 | style={[{ flex: 1 }, screenBgStyle]} | ||
| 23 | > | ||
| 24 | <Text className="text-[28px] font-t3-bold text-foreground" selectable> | ||
| 25 | Route not found | ||
| 26 | </Text> | ||
| 27 | <Link href="/" asChild> | ||
| 28 | <Pressable | ||
| 29 | style={[ | ||
| 30 | { | ||
| 31 | borderRadius: 999, | ||
| 32 | paddingHorizontal: 20, | ||
| 33 | paddingVertical: 14, | ||
| 34 | }, | ||
| 35 | primaryBgStyle, | ||
| 36 | ]} | ||
| 37 | > | ||
| 38 | <Text className="text-[16px] font-t3-bold text-primary-foreground">Return home</Text> | ||
| 39 | </Pressable> | ||
| 40 | </Link> | ||
| 41 | </ScrollView> | ||
| 42 | ); | ||
| 43 | } | ||
apps/mobile/src/app/_layout.tsx created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import "../../global.css"; | ||
| 2 | import { | ||
| 3 | DMSans_400Regular, | ||
| 4 | DMSans_500Medium, | ||
| 5 | DMSans_700Bold, | ||
| 6 | useFonts, | ||
| 7 | } from "@expo-google-fonts/dm-sans"; | ||
| 8 | import Stack from "expo-router/stack"; | ||
| 9 | import { StatusBar, useColorScheme } from "react-native"; | ||
| 10 | import { GestureHandlerRootView } from "react-native-gesture-handler"; | ||
| 11 | import { KeyboardProvider } from "react-native-keyboard-controller"; | ||
| 12 | import { SafeAreaProvider } from "react-native-safe-area-context"; | ||
| 13 | import { useCSSVariable, useResolveClassNames } from "uniwind"; | ||
| 14 | |||
| 15 | import { LoadingScreen } from "../components/LoadingScreen"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | useRemoteEnvironmentBootstrap, | ||
| 19 | useRemoteEnvironmentState, | ||
| 20 | } from "../state/use-remote-environment-registry"; | ||
| 21 | import { RegistryContext } from "@effect/atom-react"; | ||
| 22 | import { appAtomRegistry } from "../state/atom-registry"; | ||
| 23 | |||
| 24 | function AppNavigator() { | ||
| 25 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); | ||
| 26 | const colorScheme = useColorScheme(); | ||
| 27 | const statusBarBg = useCSSVariable("--color-status-bar"); | ||
| 28 | const sheetStyle = useResolveClassNames("bg-sheet"); | ||
| 29 | |||
| 30 | const newTaskScreenOptions = { | ||
| 31 | contentStyle: sheetStyle, | ||
| 32 | gestureEnabled: true, | ||
| 33 | headerShown: false, | ||
| 34 | presentation: "formSheet" as const, | ||
| 35 | sheetAllowedDetents: [0.92], | ||
| 36 | sheetGrabberVisible: true, | ||
| 37 | }; | ||
| 38 | |||
| 39 | const connectionSheetScreenOptions = { | ||
| 40 | contentStyle: sheetStyle, | ||
| 41 | gestureEnabled: true, | ||
| 42 | headerShown: false, | ||
| 43 | presentation: "formSheet" as const, | ||
| 44 | sheetAllowedDetents: [0.55, 0.7], | ||
| 45 | sheetGrabberVisible: true, | ||
| 46 | }; | ||
| 47 | |||
| 48 | if (isLoadingSavedConnection) { | ||
| 49 | return <LoadingScreen message="Loading remote workspace…" />; | ||
| 50 | } | ||
| 51 | |||
| 52 | return ( | ||
| 53 | <> | ||
| 54 | <StatusBar | ||
| 55 | barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} | ||
| 56 | backgroundColor={statusBarBg as string} | ||
| 57 | translucent | ||
| 58 | /> | ||
| 59 | <Stack screenOptions={{ headerShown: false }}> | ||
| 60 | <Stack.Screen | ||
| 61 | name="index" | ||
| 62 | options={{ | ||
| 63 | contentStyle: { backgroundColor: "transparent" }, | ||
| 64 | headerShown: true, | ||
| 65 | headerTransparent: true, | ||
| 66 | headerShadowVisible: false, | ||
| 67 | }} | ||
| 68 | /> | ||
| 69 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> | ||
| 70 | <Stack.Screen name="new" options={newTaskScreenOptions} /> | ||
| 71 | <Stack.Screen | ||
| 72 | name="threads/[environmentId]/[threadId]" | ||
| 73 | options={{ | ||
| 74 | animation: "slide_from_right", | ||
| 75 | contentStyle: { backgroundColor: "transparent" }, | ||
| 76 | gestureEnabled: true, | ||
| 77 | headerShown: false, | ||
| 78 | }} | ||
| 79 | /> | ||
| 80 | </Stack> | ||
| 81 | </> | ||
| 82 | ); | ||
| 83 | } | ||
| 84 | |||
| 85 | export default function RootLayout() { | ||
| 86 | const [fontsLoaded] = useFonts({ | ||
| 87 | DMSans_400Regular, | ||
| 88 | DMSans_500Medium, | ||
| 89 | DMSans_700Bold, | ||
| 90 | }); | ||
| 91 | useRemoteEnvironmentBootstrap(); | ||
| 92 | |||
| 93 | return ( | ||
| 94 | <RegistryContext value={appAtomRegistry}> | ||
| 95 | <GestureHandlerRootView style={{ flex: 1 }}> | ||
| 96 | <KeyboardProvider statusBarTranslucent> | ||
| 97 | <SafeAreaProvider> | ||
| 98 | {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />} | ||
| 99 | </SafeAreaProvider> | ||
| 100 | </KeyboardProvider> | ||
| 101 | </GestureHandlerRootView> | ||
| 102 | </RegistryContext> | ||
| 103 | ); | ||
| 104 | } | ||
apps/mobile/src/app/connections/_layout.tsx created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { useResolveClassNames } from "uniwind"; | ||
| 3 | |||
| 4 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 5 | |||
| 6 | export const unstable_settings = { | ||
| 7 | anchor: "index", | ||
| 8 | }; | ||
| 9 | |||
| 10 | export default function ConnectionsLayout() { | ||
| 11 | const contentStyle = useResolveClassNames("bg-sheet"); | ||
| 12 | const connSheetBg = String(useThemeColor("--color-sheet")); | ||
| 13 | const headerTint = String(useThemeColor("--color-icon")); | ||
| 14 | |||
| 15 | return ( | ||
| 16 | <Stack | ||
| 17 | screenOptions={{ | ||
| 18 | contentStyle, | ||
| 19 | headerStyle: { backgroundColor: connSheetBg }, | ||
| 20 | headerTintColor: headerTint, | ||
| 21 | headerTitleStyle: { fontFamily: "DMSans_700Bold" }, | ||
| 22 | headerShadowVisible: false, | ||
| 23 | headerBackButtonDisplayMode: "minimal", | ||
| 24 | }} | ||
| 25 | > | ||
| 26 | <Stack.Screen name="index" options={{ animation: "none" }} /> | ||
| 27 | <Stack.Screen name="new" options={{ animation: "slide_from_right" }} /> | ||
| 28 | </Stack> | ||
| 29 | ); | ||
| 30 | } | ||
apps/mobile/src/app/connections/index.tsx created+103| ... | @@ -0,0 +1,103 @@ | ||
| 1 | import { Link, Stack } from "expo-router"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import { useCallback, useState } from "react"; | ||
| 5 | import { Pressable, ScrollView, View } from "react-native"; | ||
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 8 | |||
| 9 | import { AppText as Text } from "../../components/AppText"; | ||
| 10 | import { cn } from "../../lib/cn"; | ||
| 11 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 12 | import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; | ||
| 13 | |||
| 14 | export default function ConnectionsRouteScreen() { | ||
| 15 | const { | ||
| 16 | connectedEnvironments, | ||
| 17 | onReconnectEnvironment, | ||
| 18 | onRemoveEnvironmentPress, | ||
| 19 | onUpdateEnvironment, | ||
| 20 | } = useRemoteConnections(); | ||
| 21 | const insets = useSafeAreaInsets(); | ||
| 22 | const hasEnvironments = connectedEnvironments.length > 0; | ||
| 23 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | ||
| 24 | |||
| 25 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 26 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 27 | |||
| 28 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | ||
| 29 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | ||
| 30 | }, []); | ||
| 31 | |||
| 32 | return ( | ||
| 33 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 34 | <Stack.Screen | ||
| 35 | options={{ | ||
| 36 | title: "Environments", | ||
| 37 | headerRight: () => ( | ||
| 38 | <Link href="/connections/new" asChild> | ||
| 39 | <Pressable className="h-10 w-10 items-center justify-center rounded-full bg-primary active:opacity-70"> | ||
| 40 | <SymbolView | ||
| 41 | name="plus" | ||
| 42 | size={18} | ||
| 43 | tintColor={primaryFg} | ||
| 44 | type="monochrome" | ||
| 45 | weight="semibold" | ||
| 46 | /> | ||
| 47 | </Pressable> | ||
| 48 | </Link> | ||
| 49 | ), | ||
| 50 | }} | ||
| 51 | /> | ||
| 52 | <ScrollView | ||
| 53 | contentInsetAdjustmentBehavior="automatic" | ||
| 54 | showsVerticalScrollIndicator={false} | ||
| 55 | style={{ flex: 1 }} | ||
| 56 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 57 | contentContainerStyle={{ | ||
| 58 | paddingHorizontal: 20, | ||
| 59 | paddingTop: 16, | ||
| 60 | }} | ||
| 61 | > | ||
| 62 | {hasEnvironments ? ( | ||
| 63 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 64 | {connectedEnvironments.map((environment, index) => ( | ||
| 65 | <View | ||
| 66 | key={environment.environmentId} | ||
| 67 | collapsable={false} | ||
| 68 | style={{ | ||
| 69 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 70 | }} | ||
| 71 | className={cn(index !== 0 && "border-border")} | ||
| 72 | > | ||
| 73 | <ConnectionEnvironmentRow | ||
| 74 | environment={environment} | ||
| 75 | expanded={expandedId === environment.environmentId} | ||
| 76 | onToggle={() => handleToggle(environment.environmentId)} | ||
| 77 | onReconnect={onReconnectEnvironment} | ||
| 78 | onRemove={onRemoveEnvironmentPress} | ||
| 79 | onUpdate={onUpdateEnvironment} | ||
| 80 | /> | ||
| 81 | </View> | ||
| 82 | ))} | ||
| 83 | </View> | ||
| 84 | ) : ( | ||
| 85 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> | ||
| 86 | <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle"> | ||
| 87 | <SymbolView | ||
| 88 | name="point.3.connected.trianglepath.dotted" | ||
| 89 | size={20} | ||
| 90 | tintColor={accentColor} | ||
| 91 | type="monochrome" | ||
| 92 | /> | ||
| 93 | </View> | ||
| 94 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> | ||
| 95 | No environments connected yet.{"\n"}Tap{" "} | ||
| 96 | <Text className="font-t3-bold text-foreground">+</Text> to add one. | ||
| 97 | </Text> | ||
| 98 | </View> | ||
| 99 | )} | ||
| 100 | </ScrollView> | ||
| 101 | </View> | ||
| 102 | ); | ||
| 103 | } | ||
apps/mobile/src/app/connections/new.tsx created+252| ... | @@ -0,0 +1,252 @@ | ||
| 1 | import { CameraView, useCameraPermissions } from "expo-camera"; | ||
| 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | ||
| 3 | import { SymbolView } from "expo-symbols"; | ||
| 4 | import { useCallback, useEffect, useState } from "react"; | ||
| 5 | import { Alert, Pressable, ScrollView, View } from "react-native"; | ||
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 8 | |||
| 9 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 10 | import { ErrorBanner } from "../../components/ErrorBanner"; | ||
| 11 | import { dismissRoute } from "../../lib/routes"; | ||
| 12 | import { ConnectionSheetButton } from "../../features/connection/ConnectionSheetButton"; | ||
| 13 | import { extractPairingUrlFromQrPayload } from "../../features/connection/pairing"; | ||
| 14 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| 15 | import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pairing"; | ||
| 16 | |||
| 17 | export default function ConnectionsNewRouteScreen() { | ||
| 18 | const { | ||
| 19 | connectionPairingUrl, | ||
| 20 | onChangeConnectionPairingUrl, | ||
| 21 | onConnectPress, | ||
| 22 | pairingConnectionError, | ||
| 23 | } = useRemoteConnections(); | ||
| 24 | const router = useRouter(); | ||
| 25 | const params = useLocalSearchParams<{ mode?: string }>(); | ||
| 26 | const insets = useSafeAreaInsets(); | ||
| 27 | const [hostInput, setHostInput] = useState(""); | ||
| 28 | const [codeInput, setCodeInput] = useState(""); | ||
| 29 | const [isSubmitting, setIsSubmitting] = useState(false); | ||
| 30 | const [showScanner, setShowScanner] = useState(params.mode === "scan_qr"); | ||
| 31 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); | ||
| 32 | const [scannerLocked, setScannerLocked] = useState(false); | ||
| 33 | |||
| 34 | const textColor = useThemeColor("--color-icon"); | ||
| 35 | const placeholderColor = useThemeColor("--color-placeholder"); | ||
| 36 | |||
| 37 | const connectDisabled = isSubmitting || hostInput.trim().length === 0; | ||
| 38 | |||
| 39 | useEffect(() => { | ||
| 40 | const { host, code } = parsePairingUrl(connectionPairingUrl); | ||
| 41 | setHostInput(host); | ||
| 42 | setCodeInput(code); | ||
| 43 | }, [connectionPairingUrl]); | ||
| 44 | |||
| 45 | useEffect(() => { | ||
| 46 | if (pairingConnectionError) { | ||
| 47 | setIsSubmitting(false); | ||
| 48 | } | ||
| 49 | }, [pairingConnectionError]); | ||
| 50 | |||
| 51 | const handleHostChange = useCallback((value: string) => { | ||
| 52 | setHostInput(value); | ||
| 53 | }, []); | ||
| 54 | |||
| 55 | const handleCodeChange = useCallback((value: string) => { | ||
| 56 | setCodeInput(value); | ||
| 57 | }, []); | ||
| 58 | |||
| 59 | const openScanner = useCallback(async () => { | ||
| 60 | if (cameraPermission?.granted) { | ||
| 61 | setScannerLocked(false); | ||
| 62 | setShowScanner(true); | ||
| 63 | return; | ||
| 64 | } | ||
| 65 | |||
| 66 | const permission = await requestCameraPermission(); | ||
| 67 | if (permission.granted) { | ||
| 68 | setScannerLocked(false); | ||
| 69 | setShowScanner(true); | ||
| 70 | return; | ||
| 71 | } | ||
| 72 | |||
| 73 | Alert.alert( | ||
| 74 | "Camera access needed", | ||
| 75 | "Allow camera access to scan an environment pairing QR code.", | ||
| 76 | ); | ||
| 77 | }, [cameraPermission?.granted, requestCameraPermission]); | ||
| 78 | |||
| 79 | const closeScanner = useCallback(() => { | ||
| 80 | setShowScanner(false); | ||
| 81 | setScannerLocked(false); | ||
| 82 | }, []); | ||
| 83 | |||
| 84 | const handleQrScan = useCallback( | ||
| 85 | ({ data }: { readonly data: string }) => { | ||
| 86 | if (scannerLocked) { | ||
| 87 | return; | ||
| 88 | } | ||
| 89 | |||
| 90 | setScannerLocked(true); | ||
| 91 | |||
| 92 | try { | ||
| 93 | const pairingUrl = extractPairingUrlFromQrPayload(data); | ||
| 94 | const { host, code } = parsePairingUrl(pairingUrl); | ||
| 95 | setHostInput(host); | ||
| 96 | setCodeInput(code); | ||
| 97 | onChangeConnectionPairingUrl(pairingUrl); | ||
| 98 | setShowScanner(false); | ||
| 99 | } catch (error) { | ||
| 100 | Alert.alert( | ||
| 101 | "Invalid QR code", | ||
| 102 | error instanceof Error ? error.message : "Scanned QR code was not recognized.", | ||
| 103 | ); | ||
| 104 | } finally { | ||
| 105 | setTimeout(() => { | ||
| 106 | setScannerLocked(false); | ||
| 107 | }, 600); | ||
| 108 | } | ||
| 109 | }, | ||
| 110 | [onChangeConnectionPairingUrl, scannerLocked], | ||
| 111 | ); | ||
| 112 | |||
| 113 | const handleSubmit = useCallback(async () => { | ||
| 114 | setIsSubmitting(true); | ||
| 115 | |||
| 116 | try { | ||
| 117 | const pairingUrl = buildPairingUrl(hostInput, codeInput); | ||
| 118 | onChangeConnectionPairingUrl(pairingUrl); | ||
| 119 | await onConnectPress(pairingUrl); | ||
| 120 | dismissRoute(router); | ||
| 121 | } catch { | ||
| 122 | setIsSubmitting(false); | ||
| 123 | } | ||
| 124 | }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]); | ||
| 125 | |||
| 126 | return ( | ||
| 127 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 128 | <Stack.Screen | ||
| 129 | options={{ | ||
| 130 | title: showScanner ? "Scan QR Code" : "Add Environment", | ||
| 131 | headerRight: () => ( | ||
| 132 | <Pressable | ||
| 133 | className="h-10 w-10 items-center justify-center rounded-full border border-border bg-secondary" | ||
| 134 | onPress={() => { | ||
| 135 | if (showScanner) { | ||
| 136 | closeScanner(); | ||
| 137 | } else { | ||
| 138 | void openScanner(); | ||
| 139 | } | ||
| 140 | }} | ||
| 141 | > | ||
| 142 | <SymbolView | ||
| 143 | name={showScanner ? "xmark" : "qrcode.viewfinder"} | ||
| 144 | size={showScanner ? 14 : 18} | ||
| 145 | tintColor={textColor} | ||
| 146 | type="monochrome" | ||
| 147 | weight="semibold" | ||
| 148 | /> | ||
| 149 | </Pressable> | ||
| 150 | ), | ||
| 151 | }} | ||
| 152 | /> | ||
| 153 | |||
| 154 | <ScrollView | ||
| 155 | contentInsetAdjustmentBehavior="automatic" | ||
| 156 | showsVerticalScrollIndicator={false} | ||
| 157 | style={{ flex: 1 }} | ||
| 158 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 159 | contentContainerStyle={{ | ||
| 160 | paddingHorizontal: 20, | ||
| 161 | paddingTop: 16, | ||
| 162 | }} | ||
| 163 | > | ||
| 164 | <View collapsable={false} className="gap-5"> | ||
| 165 | {showScanner ? ( | ||
| 166 | cameraPermission?.granted ? ( | ||
| 167 | <View | ||
| 168 | className="overflow-hidden rounded-[24px]" | ||
| 169 | style={{ borderCurve: "continuous" }} | ||
| 170 | > | ||
| 171 | <CameraView | ||
| 172 | barcodeScannerSettings={{ barcodeTypes: ["qr"] }} | ||
| 173 | onBarcodeScanned={handleQrScan} | ||
| 174 | style={{ aspectRatio: 1, width: "100%" }} | ||
| 175 | /> | ||
| 176 | </View> | ||
| 177 | ) : ( | ||
| 178 | <View | ||
| 179 | className="items-center gap-3 rounded-[24px] bg-card px-5 py-8" | ||
| 180 | style={{ borderCurve: "continuous" }} | ||
| 181 | > | ||
| 182 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> | ||
| 183 | Camera permission is required to scan a QR code. | ||
| 184 | </Text> | ||
| 185 | <ConnectionSheetButton | ||
| 186 | compact | ||
| 187 | icon="camera" | ||
| 188 | label="Allow camera" | ||
| 189 | tone="secondary" | ||
| 190 | onPress={() => { | ||
| 191 | void openScanner(); | ||
| 192 | }} | ||
| 193 | /> | ||
| 194 | </View> | ||
| 195 | ) | ||
| 196 | ) : ( | ||
| 197 | <View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4"> | ||
| 198 | <View collapsable={false} className="gap-1.5"> | ||
| 199 | <Text | ||
| 200 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" | ||
| 201 | style={{ letterSpacing: 0.8 }} | ||
| 202 | > | ||
| 203 | Host | ||
| 204 | </Text> | ||
| 205 | <TextInput | ||
| 206 | autoCapitalize="none" | ||
| 207 | autoCorrect={false} | ||
| 208 | keyboardType="url" | ||
| 209 | placeholder="192.168.1.100:8080" | ||
| 210 | placeholderTextColor={placeholderColor} | ||
| 211 | value={hostInput} | ||
| 212 | onChangeText={handleHostChange} | ||
| 213 | className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground" | ||
| 214 | /> | ||
| 215 | </View> | ||
| 216 | |||
| 217 | <View collapsable={false} className="gap-1.5"> | ||
| 218 | <Text | ||
| 219 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" | ||
| 220 | style={{ letterSpacing: 0.8 }} | ||
| 221 | > | ||
| 222 | Pairing code | ||
| 223 | </Text> | ||
| 224 | <TextInput | ||
| 225 | autoCapitalize="none" | ||
| 226 | autoCorrect={false} | ||
| 227 | placeholder="abc-123-xyz" | ||
| 228 | placeholderTextColor={placeholderColor} | ||
| 229 | value={codeInput} | ||
| 230 | onChangeText={handleCodeChange} | ||
| 231 | className="rounded-[14px] border border-input-border bg-input px-4 py-3.5 text-[15px] text-foreground" | ||
| 232 | /> | ||
| 233 | </View> | ||
| 234 | |||
| 235 | {pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null} | ||
| 236 | |||
| 237 | <ConnectionSheetButton | ||
| 238 | icon="plus" | ||
| 239 | label={isSubmitting ? "Pairing..." : "Add environment"} | ||
| 240 | disabled={connectDisabled} | ||
| 241 | tone="primary" | ||
| 242 | onPress={() => { | ||
| 243 | void handleSubmit(); | ||
| 244 | }} | ||
| 245 | /> | ||
| 246 | </View> | ||
| 247 | )} | ||
| 248 | </View> | ||
| 249 | </ScrollView> | ||
| 250 | </View> | ||
| 251 | ); | ||
| 252 | } | ||
apps/mobile/src/app/index.tsx created+117| ... | @@ -0,0 +1,117 @@ | ||
| 1 | import { Stack, useRouter } from "expo-router"; | ||
| 2 | import { useState } from "react"; | ||
| 3 | import { Text as RNText, View, useColorScheme } from "react-native"; | ||
| 4 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 5 | |||
| 6 | import { buildThreadRoutePath } from "../lib/routes"; | ||
| 7 | import { useRemoteCatalog } from "../state/use-remote-catalog"; | ||
| 8 | import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry"; | ||
| 9 | import { HomeScreen } from "../features/home/HomeScreen"; | ||
| 10 | |||
| 11 | /* ─── Route screen ───────────────────────────────────────────────────── */ | ||
| 12 | |||
| 13 | export default function HomeRouteScreen() { | ||
| 14 | const { projects, state: catalogState, threads } = useRemoteCatalog(); | ||
| 15 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 16 | const router = useRouter(); | ||
| 17 | const [searchQuery, setSearchQuery] = useState(""); | ||
| 18 | |||
| 19 | const isDark = useColorScheme() === "dark"; | ||
| 20 | const iconColor = String(useThemeColor("--color-icon")); | ||
| 21 | |||
| 22 | return ( | ||
| 23 | <> | ||
| 24 | <Stack.Screen | ||
| 25 | options={{ | ||
| 26 | headerShown: true, | ||
| 27 | headerTransparent: true, | ||
| 28 | headerStyle: { backgroundColor: "transparent" }, | ||
| 29 | headerShadowVisible: false, | ||
| 30 | headerTintColor: iconColor, | ||
| 31 | headerTitle: "", | ||
| 32 | headerSearchBarOptions: { | ||
| 33 | placeholder: "Search threads", | ||
| 34 | onChangeText: (event) => { | ||
| 35 | setSearchQuery(event.nativeEvent.text); | ||
| 36 | }, | ||
| 37 | allowToolbarIntegration: true, | ||
| 38 | }, | ||
| 39 | }} | ||
| 40 | /> | ||
| 41 | |||
| 42 | {/* Header left: plain text, no Liquid Glass button chrome */} | ||
| 43 | <Stack.Toolbar placement="left"> | ||
| 44 | <Stack.Toolbar.View hidesSharedBackground> | ||
| 45 | <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}> | ||
| 46 | <RNText | ||
| 47 | style={{ | ||
| 48 | fontFamily: "DMSans_700Bold", | ||
| 49 | fontSize: 17, | ||
| 50 | color: iconColor, | ||
| 51 | letterSpacing: -0.4, | ||
| 52 | }} | ||
| 53 | > | ||
| 54 | T3 Code | ||
| 55 | </RNText> | ||
| 56 | <View | ||
| 57 | style={{ | ||
| 58 | backgroundColor: isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)", | ||
| 59 | borderRadius: 99, | ||
| 60 | paddingHorizontal: 8, | ||
| 61 | paddingVertical: 3, | ||
| 62 | }} | ||
| 63 | > | ||
| 64 | <RNText | ||
| 65 | style={{ | ||
| 66 | fontFamily: "DMSans_700Bold", | ||
| 67 | fontSize: 10, | ||
| 68 | color: "#737373", | ||
| 69 | letterSpacing: 1.1, | ||
| 70 | textTransform: "uppercase", | ||
| 71 | }} | ||
| 72 | > | ||
| 73 | Alpha | ||
| 74 | </RNText> | ||
| 75 | </View> | ||
| 76 | </View> | ||
| 77 | </Stack.Toolbar.View> | ||
| 78 | </Stack.Toolbar> | ||
| 79 | |||
| 80 | <Stack.Toolbar placement="right"> | ||
| 81 | <Stack.Toolbar.Menu icon="ellipsis.circle" separateBackground> | ||
| 82 | <Stack.Toolbar.Label>More</Stack.Toolbar.Label> | ||
| 83 | <Stack.Toolbar.MenuAction | ||
| 84 | icon="desktopcomputer" | ||
| 85 | onPress={() => router.push("/connections")} | ||
| 86 | subtitle="Manage connected hosts" | ||
| 87 | > | ||
| 88 | <Stack.Toolbar.Label>Environments</Stack.Toolbar.Label> | ||
| 89 | </Stack.Toolbar.MenuAction> | ||
| 90 | </Stack.Toolbar.Menu> | ||
| 91 | </Stack.Toolbar> | ||
| 92 | |||
| 93 | {/* Bottom toolbar: search + compose, visually split like iMessage */} | ||
| 94 | <Stack.Toolbar placement="bottom"> | ||
| 95 | <Stack.Toolbar.SearchBarSlot /> | ||
| 96 | <Stack.Toolbar.Spacer width={8} sharesBackground={false} /> | ||
| 97 | <Stack.Toolbar.Button | ||
| 98 | icon="square.and.pencil" | ||
| 99 | onPress={() => router.push("/new")} | ||
| 100 | separateBackground | ||
| 101 | /> | ||
| 102 | </Stack.Toolbar> | ||
| 103 | |||
| 104 | <HomeScreen | ||
| 105 | projects={projects} | ||
| 106 | threads={threads} | ||
| 107 | catalogState={catalogState} | ||
| 108 | savedConnectionsById={savedConnectionsById} | ||
| 109 | searchQuery={searchQuery} | ||
| 110 | onAddConnection={() => router.push("/connections/new")} | ||
| 111 | onSelectThread={(thread) => { | ||
| 112 | router.push(buildThreadRoutePath(thread)); | ||
| 113 | }} | ||
| 114 | /> | ||
| 115 | </> | ||
| 116 | ); | ||
| 117 | } | ||
apps/mobile/src/app/new/_layout.tsx created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { useResolveClassNames } from "uniwind"; | ||
| 3 | |||
| 4 | import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; | ||
| 5 | |||
| 6 | export const unstable_settings = { | ||
| 7 | anchor: "index", | ||
| 8 | }; | ||
| 9 | |||
| 10 | export default function NewTaskLayout() { | ||
| 11 | const sheetStyle = useResolveClassNames("bg-sheet"); | ||
| 12 | |||
| 13 | return ( | ||
| 14 | <NewTaskFlowProvider> | ||
| 15 | <Stack | ||
| 16 | screenOptions={{ | ||
| 17 | contentStyle: sheetStyle, | ||
| 18 | }} | ||
| 19 | > | ||
| 20 | <Stack.Screen name="index" options={{ animation: "none" }} /> | ||
| 21 | <Stack.Screen | ||
| 22 | name="add-project" | ||
| 23 | options={{ animation: "slide_from_right", headerShown: false }} | ||
| 24 | /> | ||
| 25 | <Stack.Screen name="draft" options={{ animation: "slide_from_right" }} /> | ||
| 26 | </Stack> | ||
| 27 | </NewTaskFlowProvider> | ||
| 28 | ); | ||
| 29 | } | ||
apps/mobile/src/app/new/add-project/_layout.tsx created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | import { Stack } from "expo-router"; | ||
| 2 | import { useThemeColor } from "../../../lib/useThemeColor"; | ||
| 3 | |||
| 4 | export default function AddProjectLayout() { | ||
| 5 | const sheetStyle = { | ||
| 6 | backgroundColor: useThemeColor("--color-sheet"), | ||
| 7 | }; | ||
| 8 | |||
| 9 | return ( | ||
| 10 | <Stack screenOptions={{ headerShown: false, contentStyle: sheetStyle }}> | ||
| 11 | <Stack.Screen name="index" options={{ animation: "slide_from_right" }} /> | ||
| 12 | <Stack.Screen name="repository" options={{ animation: "slide_from_right" }} /> | ||
| 13 | <Stack.Screen name="destination" options={{ animation: "slide_from_right" }} /> | ||
| 14 | <Stack.Screen name="local" options={{ animation: "slide_from_right" }} /> | ||
| 15 | </Stack> | ||
| 16 | ); | ||
| 17 | } | ||
apps/mobile/src/app/new/add-project/destination.tsx created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import { Stack } from "expo-router"; | ||
| 2 | |||
| 3 | import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen"; | ||
| 4 | |||
| 5 | export default function AddProjectDestinationRoute() { | ||
| 6 | return ( | ||
| 7 | <> | ||
| 8 | <Stack.Screen options={{ headerShown: false }} /> | ||
| 9 | <AddProjectDestinationScreen /> | ||
| 10 | </> | ||
| 11 | ); | ||
| 12 | } | ||
apps/mobile/src/app/new/add-project/index.tsx created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import { Stack } from "expo-router"; | ||
| 2 | |||
| 3 | import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen"; | ||
| 4 | |||
| 5 | export default function AddProjectRoute() { | ||
| 6 | return ( | ||
| 7 | <> | ||
| 8 | <Stack.Screen options={{ headerShown: false }} /> | ||
| 9 | <AddProjectSourceScreen /> | ||
| 10 | </> | ||
| 11 | ); | ||
| 12 | } | ||
apps/mobile/src/app/new/add-project/local.tsx created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import { Stack } from "expo-router"; | ||
| 2 | |||
| 3 | import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen"; | ||
| 4 | |||
| 5 | export default function AddProjectLocalRoute() { | ||
| 6 | return ( | ||
| 7 | <> | ||
| 8 | <Stack.Screen options={{ headerShown: false }} /> | ||
| 9 | <AddProjectLocalFolderScreen /> | ||
| 10 | </> | ||
| 11 | ); | ||
| 12 | } | ||
apps/mobile/src/app/new/add-project/repository.tsx created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import { Stack } from "expo-router"; | ||
| 2 | |||
| 3 | import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen"; | ||
| 4 | |||
| 5 | export default function AddProjectRepositoryRoute() { | ||
| 6 | return ( | ||
| 7 | <> | ||
| 8 | <Stack.Screen options={{ headerShown: false }} /> | ||
| 9 | <AddProjectRepositoryScreen /> | ||
| 10 | </> | ||
| 11 | ); | ||
| 12 | } | ||
apps/mobile/src/app/new/draft.tsx created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import { Stack, useLocalSearchParams } from "expo-router"; | ||
| 2 | |||
| 3 | import { NewTaskDraftScreen } from "../../features/threads/NewTaskDraftScreen"; | ||
| 4 | |||
| 5 | export default function NewTaskDraftRoute() { | ||
| 6 | const params = useLocalSearchParams<{ | ||
| 7 | environmentId?: string | string[]; | ||
| 8 | projectId?: string | string[]; | ||
| 9 | }>(); | ||
| 10 | |||
| 11 | return ( | ||
| 12 | <> | ||
| 13 | <Stack.Screen options={{ headerShown: false }} /> | ||
| 14 | <NewTaskDraftScreen | ||
| 15 | initialProjectRef={{ | ||
| 16 | environmentId: Array.isArray(params.environmentId) | ||
| 17 | ? params.environmentId[0] | ||
| 18 | : params.environmentId, | ||
| 19 | projectId: Array.isArray(params.projectId) ? params.projectId[0] : params.projectId, | ||
| 20 | }} | ||
| 21 | /> | ||
| 22 | </> | ||
| 23 | ); | ||
| 24 | } | ||
apps/mobile/src/app/new/index.tsx created+232| ... | @@ -0,0 +1,232 @@ | ||
| 1 | import { Link, Stack, useRouter } from "expo-router"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | ||
| 4 | import { useMemo } from "react"; | ||
| 5 | import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; | ||
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 8 | |||
| 9 | import { AppText as Text } from "../../components/AppText"; | ||
| 10 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | ||
| 11 | import { NewTaskSheetHeader } from "../../features/threads/NewTaskSheetHeader"; | ||
| 12 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | ||
| 13 | import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog"; | ||
| 14 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | ||
| 15 | |||
| 16 | function deriveProjectEmptyState(catalogState: RemoteCatalogState): { | ||
| 17 | readonly title: string; | ||
| 18 | readonly detail: string; | ||
| 19 | readonly loading: boolean; | ||
| 20 | } { | ||
| 21 | if (catalogState.isLoadingSavedConnections) { | ||
| 22 | return { | ||
| 23 | title: "Loading environments", | ||
| 24 | detail: "Checking saved environments on this device.", | ||
| 25 | loading: true, | ||
| 26 | }; | ||
| 27 | } | ||
| 28 | |||
| 29 | if (!catalogState.hasSavedConnections) { | ||
| 30 | return { | ||
| 31 | title: "No environments connected", | ||
| 32 | detail: "Add an environment before creating a task.", | ||
| 33 | loading: false, | ||
| 34 | }; | ||
| 35 | } | ||
| 36 | |||
| 37 | if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) { | ||
| 38 | return { | ||
| 39 | title: "Environment unavailable", | ||
| 40 | detail: | ||
| 41 | catalogState.connectionError ?? | ||
| 42 | "The saved environment is offline. Check the URL or start the environment, then retry.", | ||
| 43 | loading: false, | ||
| 44 | }; | ||
| 45 | } | ||
| 46 | |||
| 47 | if ( | ||
| 48 | catalogState.hasConnectingEnvironment && | ||
| 49 | !catalogState.hasLoadedShellSnapshot && | ||
| 50 | catalogState.connectionError === null | ||
| 51 | ) { | ||
| 52 | return { | ||
| 53 | title: "Connecting to environment", | ||
| 54 | detail: "Loading projects from the saved environment.", | ||
| 55 | loading: true, | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | |||
| 59 | return { | ||
| 60 | title: "No projects found", | ||
| 61 | detail: "The connected environment did not report any projects.", | ||
| 62 | loading: false, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | export default function NewTaskRoute() { | ||
| 67 | const { projects, state: catalogState, threads } = useRemoteCatalog(); | ||
| 68 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 69 | const router = useRouter(); | ||
| 70 | const insets = useSafeAreaInsets(); | ||
| 71 | const chevronColor = useThemeColor("--color-chevron"); | ||
| 72 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 73 | const borderSubtleColor = useThemeColor("--color-border-subtle"); | ||
| 74 | const repositoryGroups = useMemo( | ||
| 75 | () => groupProjectsByRepository({ projects, threads }), | ||
| 76 | [projects, threads], | ||
| 77 | ); | ||
| 78 | const items = useMemo(() => { | ||
| 79 | const nextItems: Array<{ | ||
| 80 | readonly environmentId: EnvironmentId; | ||
| 81 | readonly id: ProjectId; | ||
| 82 | readonly key: string; | ||
| 83 | readonly title: string; | ||
| 84 | readonly workspaceRoot: string; | ||
| 85 | }> = []; | ||
| 86 | for (const group of repositoryGroups) { | ||
| 87 | const project = group.projects[0]?.project; | ||
| 88 | if (!project) { | ||
| 89 | continue; | ||
| 90 | } | ||
| 91 | nextItems.push({ | ||
| 92 | environmentId: project.environmentId, | ||
| 93 | id: project.id, | ||
| 94 | key: group.key, | ||
| 95 | title: project.title, | ||
| 96 | workspaceRoot: project.workspaceRoot, | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | return nextItems; | ||
| 100 | }, [repositoryGroups]); | ||
| 101 | const projectEmptyState = deriveProjectEmptyState(catalogState); | ||
| 102 | |||
| 103 | return ( | ||
| 104 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 105 | <Stack.Screen options={{ headerShown: false }} /> | ||
| 106 | |||
| 107 | <NewTaskSheetHeader title="Choose project" /> | ||
| 108 | |||
| 109 | <ScrollView | ||
| 110 | showsVerticalScrollIndicator={false} | ||
| 111 | style={{ flex: 1 }} | ||
| 112 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 113 | contentContainerStyle={{ | ||
| 114 | paddingHorizontal: 20, | ||
| 115 | paddingTop: 8, | ||
| 116 | }} | ||
| 117 | > | ||
| 118 | {items.length === 0 ? ( | ||
| 119 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> | ||
| 120 | {projectEmptyState.loading ? <ActivityIndicator color={accentColor} /> : null} | ||
| 121 | <Text className="text-center text-[17px] font-t3-bold text-foreground"> | ||
| 122 | {projectEmptyState.title} | ||
| 123 | </Text> | ||
| 124 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> | ||
| 125 | {projectEmptyState.detail} | ||
| 126 | </Text> | ||
| 127 | {!catalogState.hasReadyEnvironment ? ( | ||
| 128 | <Pressable | ||
| 129 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | ||
| 130 | onPress={() => router.push("/connections/new")} | ||
| 131 | > | ||
| 132 | <Text className="text-[13px] font-t3-bold text-primary-foreground"> | ||
| 133 | Add environment | ||
| 134 | </Text> | ||
| 135 | </Pressable> | ||
| 136 | ) : ( | ||
| 137 | <Pressable | ||
| 138 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | ||
| 139 | onPress={() => router.push("/new/add-project")} | ||
| 140 | > | ||
| 141 | <Text className="text-[13px] font-t3-bold text-primary-foreground"> | ||
| 142 | Add new project | ||
| 143 | </Text> | ||
| 144 | </Pressable> | ||
| 145 | )} | ||
| 146 | </View> | ||
| 147 | ) : ( | ||
| 148 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 149 | {items.map((item, index) => { | ||
| 150 | const isFirst = index === 0; | ||
| 151 | |||
| 152 | return ( | ||
| 153 | <Link | ||
| 154 | key={item.key} | ||
| 155 | href={{ | ||
| 156 | pathname: "/new/draft", | ||
| 157 | params: { | ||
| 158 | environmentId: item.environmentId, | ||
| 159 | projectId: item.id, | ||
| 160 | title: item.title, | ||
| 161 | }, | ||
| 162 | }} | ||
| 163 | asChild | ||
| 164 | > | ||
| 165 | <Pressable | ||
| 166 | className="bg-card" | ||
| 167 | style={{ | ||
| 168 | paddingHorizontal: 16, | ||
| 169 | paddingVertical: 18, | ||
| 170 | borderTopWidth: isFirst ? 0 : 1, | ||
| 171 | borderTopColor: borderSubtleColor, | ||
| 172 | borderTopLeftRadius: isFirst ? 24 : 0, | ||
| 173 | borderTopRightRadius: isFirst ? 24 : 0, | ||
| 174 | borderBottomLeftRadius: 0, | ||
| 175 | borderBottomRightRadius: 0, | ||
| 176 | }} | ||
| 177 | > | ||
| 178 | <View className="flex-row items-center justify-between gap-3"> | ||
| 179 | <ProjectFavicon | ||
| 180 | size={22} | ||
| 181 | projectTitle={item.title} | ||
| 182 | httpBaseUrl={savedConnectionsById[item.environmentId]?.httpBaseUrl ?? null} | ||
| 183 | workspaceRoot={item.workspaceRoot} | ||
| 184 | bearerToken={savedConnectionsById[item.environmentId]?.bearerToken ?? null} | ||
| 185 | /> | ||
| 186 | <View className="flex-1"> | ||
| 187 | <Text className="text-[18px] font-t3-bold">{item.title}</Text> | ||
| 188 | </View> | ||
| 189 | <SymbolView | ||
| 190 | name="chevron.right" | ||
| 191 | size={14} | ||
| 192 | tintColor={chevronColor} | ||
| 193 | type="monochrome" | ||
| 194 | /> | ||
| 195 | </View> | ||
| 196 | </Pressable> | ||
| 197 | </Link> | ||
| 198 | ); | ||
| 199 | })} | ||
| 200 | <Pressable | ||
| 201 | className="bg-card" | ||
| 202 | style={{ | ||
| 203 | paddingHorizontal: 16, | ||
| 204 | paddingVertical: 18, | ||
| 205 | borderTopWidth: 1, | ||
| 206 | borderTopColor: borderSubtleColor, | ||
| 207 | borderBottomLeftRadius: 24, | ||
| 208 | borderBottomRightRadius: 24, | ||
| 209 | }} | ||
| 210 | onPress={() => router.push("/new/add-project")} | ||
| 211 | > | ||
| 212 | <View className="flex-row items-center justify-between gap-3"> | ||
| 213 | <View className="h-[22px] w-[22px] items-center justify-center rounded-full bg-subtle"> | ||
| 214 | <SymbolView name="plus" size={13} tintColor={accentColor} type="monochrome" /> | ||
| 215 | </View> | ||
| 216 | <View className="flex-1"> | ||
| 217 | <Text className="text-[18px] font-t3-bold">Add new project</Text> | ||
| 218 | </View> | ||
| 219 | <SymbolView | ||
| 220 | name="chevron.right" | ||
| 221 | size={14} | ||
| 222 | tintColor={chevronColor} | ||
| 223 | type="monochrome" | ||
| 224 | /> | ||
| 225 | </View> | ||
| 226 | </Pressable> | ||
| 227 | </View> | ||
| 228 | )} | ||
| 229 | </ScrollView> | ||
| 230 | </View> | ||
| 231 | ); | ||
| 232 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx created+80| ... | @@ -0,0 +1,80 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { StyleSheet } from "react-native"; | ||
| 3 | import { useResolveClassNames } from "uniwind"; | ||
| 4 | |||
| 5 | export default function ThreadLayout() { | ||
| 6 | const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet")); | ||
| 7 | const headerBg = { | ||
| 8 | backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor, | ||
| 9 | }; | ||
| 10 | |||
| 11 | return ( | ||
| 12 | <Stack screenOptions={{ headerShown: false }}> | ||
| 13 | <Stack.Screen | ||
| 14 | name="index" | ||
| 15 | options={{ | ||
| 16 | contentStyle: { backgroundColor: "transparent" }, | ||
| 17 | headerShown: true, | ||
| 18 | headerTransparent: true, | ||
| 19 | headerShadowVisible: false, | ||
| 20 | }} | ||
| 21 | /> | ||
| 22 | <Stack.Screen | ||
| 23 | name="git" | ||
| 24 | options={{ | ||
| 25 | contentStyle: sheetStyle, | ||
| 26 | gestureEnabled: true, | ||
| 27 | headerShown: false, | ||
| 28 | presentation: "formSheet" as const, | ||
| 29 | sheetAllowedDetents: [0.85], | ||
| 30 | sheetGrabberVisible: true, | ||
| 31 | }} | ||
| 32 | /> | ||
| 33 | <Stack.Screen | ||
| 34 | name="git-confirm" | ||
| 35 | options={{ | ||
| 36 | contentStyle: sheetStyle, | ||
| 37 | gestureEnabled: true, | ||
| 38 | headerShown: false, | ||
| 39 | presentation: "formSheet" as const, | ||
| 40 | sheetAllowedDetents: [0.4], | ||
| 41 | sheetGrabberVisible: true, | ||
| 42 | }} | ||
| 43 | /> | ||
| 44 | <Stack.Screen | ||
| 45 | name="review" | ||
| 46 | options={{ | ||
| 47 | animation: "slide_from_right", | ||
| 48 | contentStyle: sheetStyle, | ||
| 49 | headerBackButtonDisplayMode: "minimal", | ||
| 50 | headerShown: true, | ||
| 51 | headerTitle: "Files changed", | ||
| 52 | headerBackTitle: "", | ||
| 53 | headerShadowVisible: false, | ||
| 54 | headerStyle: headerBg, | ||
| 55 | }} | ||
| 56 | /> | ||
| 57 | <Stack.Screen | ||
| 58 | name="review-comment" | ||
| 59 | options={{ | ||
| 60 | contentStyle: sheetStyle, | ||
| 61 | gestureEnabled: true, | ||
| 62 | headerShown: false, | ||
| 63 | presentation: "formSheet" as const, | ||
| 64 | sheetAllowedDetents: [0.72, 0.92], | ||
| 65 | sheetGrabberVisible: true, | ||
| 66 | }} | ||
| 67 | /> | ||
| 68 | <Stack.Screen | ||
| 69 | name="terminal" | ||
| 70 | options={{ | ||
| 71 | animation: "slide_from_right", | ||
| 72 | contentStyle: { backgroundColor: "#050505" }, | ||
| 73 | headerBackButtonDisplayMode: "minimal", | ||
| 74 | headerShown: true, | ||
| 75 | headerShadowVisible: false, | ||
| 76 | }} | ||
| 77 | /> | ||
| 78 | </Stack> | ||
| 79 | ); | ||
| 80 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet"; | ||
| 2 | |||
| 3 | export default function GitConfirmRoute() { | ||
| 4 | return <GitConfirmSheet />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { StyleSheet } from "react-native"; | ||
| 3 | import { useResolveClassNames } from "uniwind"; | ||
| 4 | |||
| 5 | export const unstable_settings = { | ||
| 6 | anchor: "index", | ||
| 7 | }; | ||
| 8 | |||
| 9 | export default function GitSheetLayout() { | ||
| 10 | const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet")); | ||
| 11 | const headerBg = { | ||
| 12 | backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor, | ||
| 13 | }; | ||
| 14 | |||
| 15 | return ( | ||
| 16 | <Stack | ||
| 17 | screenOptions={{ | ||
| 18 | contentStyle: sheetStyle, | ||
| 19 | headerShown: false, | ||
| 20 | }} | ||
| 21 | > | ||
| 22 | <Stack.Screen name="index" options={{ animation: "none" }} /> | ||
| 23 | <Stack.Screen | ||
| 24 | name="commit" | ||
| 25 | options={{ | ||
| 26 | animation: "slide_from_right", | ||
| 27 | headerShown: true, | ||
| 28 | headerTitle: "Commit changes", | ||
| 29 | headerBackTitle: "", | ||
| 30 | headerShadowVisible: false, | ||
| 31 | headerStyle: headerBg, | ||
| 32 | }} | ||
| 33 | /> | ||
| 34 | <Stack.Screen | ||
| 35 | name="branches" | ||
| 36 | options={{ | ||
| 37 | animation: "slide_from_right", | ||
| 38 | headerShown: true, | ||
| 39 | headerTitle: "Branches & worktrees", | ||
| 40 | headerBackTitle: "", | ||
| 41 | headerShadowVisible: false, | ||
| 42 | headerStyle: headerBg, | ||
| 43 | }} | ||
| 44 | /> | ||
| 45 | <Stack.Screen | ||
| 46 | name="review" | ||
| 47 | options={{ | ||
| 48 | animation: "slide_from_right", | ||
| 49 | headerShown: true, | ||
| 50 | headerTitle: "Review changes", | ||
| 51 | headerBackTitle: "", | ||
| 52 | headerShadowVisible: false, | ||
| 53 | headerStyle: headerBg, | ||
| 54 | }} | ||
| 55 | /> | ||
| 56 | </Stack> | ||
| 57 | ); | ||
| 58 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet"; | ||
| 2 | |||
| 3 | export default function GitBranchesRoute() { | ||
| 4 | return <GitBranchesSheet />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet"; | ||
| 2 | |||
| 3 | export default function GitCommitRoute() { | ||
| 4 | return <GitCommitSheet />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet"; | ||
| 2 | |||
| 3 | export default function GitRoute() { | ||
| 4 | return <GitOverviewSheet />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | import { Redirect, useLocalSearchParams } from "expo-router"; | ||
| 2 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | export default function ReviewRoute() { | ||
| 5 | const { environmentId, threadId } = useLocalSearchParams<{ | ||
| 6 | environmentId: EnvironmentId; | ||
| 7 | threadId: ThreadId; | ||
| 8 | }>(); | ||
| 9 | |||
| 10 | return ( | ||
| 11 | <Redirect | ||
| 12 | href={{ | ||
| 13 | pathname: "/threads/[environmentId]/[threadId]/review", | ||
| 14 | params: { environmentId, threadId }, | ||
| 15 | }} | ||
| 16 | /> | ||
| 17 | ); | ||
| 18 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen"; | ||
| 2 | |||
| 3 | export default function ThreadRoute() { | ||
| 4 | return <ThreadRouteScreen />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { ReviewCommentComposerSheet } from "../../../../features/review/ReviewCommentComposerSheet"; | ||
| 2 | |||
| 3 | export default function ReviewCommentRoute() { | ||
| 4 | return <ReviewCommentComposerSheet />; | ||
| 5 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | import { ReviewHighlighterProvider } from "../../../../features/review/ReviewHighlighterProvider"; | ||
| 2 | import { ReviewSheet } from "../../../../features/review/ReviewSheet"; | ||
| 3 | |||
| 4 | export default function ReviewRoute() { | ||
| 5 | return ( | ||
| 6 | <ReviewHighlighterProvider> | ||
| 7 | <ReviewSheet /> | ||
| 8 | </ReviewHighlighterProvider> | ||
| 9 | ); | ||
| 10 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { ThreadTerminalRouteScreen } from "../../../../features/terminal/ThreadTerminalRouteScreen"; | ||
| 2 | |||
| 3 | export default function ThreadTerminalRoute() { | ||
| 4 | return <ThreadTerminalRouteScreen />; | ||
| 5 | } | ||
apps/mobile/src/components/AppText.tsx created+50| ... | @@ -0,0 +1,50 @@ | ||
| 1 | import type { Ref } from "react"; | ||
| 2 | import { | ||
| 3 | Text as RNText, | ||
| 4 | TextInput as RNTextInput, | ||
| 5 | type TextInputProps as RNTextInputProps, | ||
| 6 | type TextProps as RNTextProps, | ||
| 7 | } from "react-native"; | ||
| 8 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 9 | |||
| 10 | import { cn } from "../lib/cn"; | ||
| 11 | |||
| 12 | export type AppTextProps = RNTextProps & { readonly className?: string }; | ||
| 13 | |||
| 14 | /** | ||
| 15 | * Thin wrapper around RN Text with default font-family and foreground color. | ||
| 16 | * Uses Uniwind className — no manual style parsing. | ||
| 17 | */ | ||
| 18 | export function AppText({ className, ...props }: AppTextProps) { | ||
| 19 | return <RNText className={cn("font-sans text-foreground", className)} {...props} />; | ||
| 20 | } | ||
| 21 | |||
| 22 | export type AppTextInputProps = RNTextInputProps & { | ||
| 23 | readonly className?: string; | ||
| 24 | readonly ref?: Ref<RNTextInput>; | ||
| 25 | }; | ||
| 26 | |||
| 27 | /** | ||
| 28 | * Thin wrapper around RN TextInput with default input styling. | ||
| 29 | * Uses Uniwind className — no manual style parsing. | ||
| 30 | */ | ||
| 31 | export function AppTextInput({ | ||
| 32 | className, | ||
| 33 | placeholderTextColor, | ||
| 34 | ref, | ||
| 35 | ...props | ||
| 36 | }: AppTextInputProps) { | ||
| 37 | const placeholderColor = useThemeColor("--color-placeholder"); | ||
| 38 | |||
| 39 | return ( | ||
| 40 | <RNTextInput | ||
| 41 | ref={ref} | ||
| 42 | className={cn( | ||
| 43 | "min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-[15px] text-foreground", | ||
| 44 | className, | ||
| 45 | )} | ||
| 46 | placeholderTextColor={placeholderTextColor ?? placeholderColor} | ||
| 47 | {...props} | ||
| 48 | /> | ||
| 49 | ); | ||
| 50 | } | ||
apps/mobile/src/components/BrandMark.tsx created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import { Image, View } from "react-native"; | ||
| 2 | |||
| 3 | import { AppText as Text } from "./AppText"; | ||
| 4 | |||
| 5 | const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); | ||
| 6 | |||
| 7 | export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { | ||
| 8 | const compact = props.compact ?? false; | ||
| 9 | const iconSize = compact ? 32 : 44; | ||
| 10 | const stageLabel = props.stageLabel ?? "Alpha"; | ||
| 11 | |||
| 12 | return ( | ||
| 13 | <View className="flex-row items-center gap-3"> | ||
| 14 | <Image | ||
| 15 | source={BRAND_MARK_SOURCE} | ||
| 16 | accessibilityIgnoresInvertColors | ||
| 17 | style={{ | ||
| 18 | width: iconSize, | ||
| 19 | height: iconSize, | ||
| 20 | borderRadius: compact ? 10 : 14, | ||
| 21 | }} | ||
| 22 | /> | ||
| 23 | <View className="gap-1"> | ||
| 24 | <View className="flex-row items-center gap-2"> | ||
| 25 | <Text | ||
| 26 | className="text-[17px] font-t3-bold text-foreground" | ||
| 27 | style={{ letterSpacing: -0.4 }} | ||
| 28 | > | ||
| 29 | T3 Code | ||
| 30 | </Text> | ||
| 31 | <View className="rounded-full bg-subtle px-2 py-1"> | ||
| 32 | <Text | ||
| 33 | className="text-[10px] font-t3-bold uppercase text-foreground-muted" | ||
| 34 | style={{ letterSpacing: 1.1 }} | ||
| 35 | > | ||
| 36 | {stageLabel} | ||
| 37 | </Text> | ||
| 38 | </View> | ||
| 39 | </View> | ||
| 40 | {!compact ? ( | ||
| 41 | <Text className="text-[12px] font-medium text-foreground-muted"> | ||
| 42 | Mobile control surface for your live coding environments | ||
| 43 | </Text> | ||
| 44 | ) : null} | ||
| 45 | </View> | ||
| 46 | </View> | ||
| 47 | ); | ||
| 48 | } | ||
apps/mobile/src/components/ComposerAttachmentStrip.tsx created+96| ... | @@ -0,0 +1,96 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { Image, Pressable, ScrollView, View } from "react-native"; | ||
| 3 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 4 | |||
| 5 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | ||
| 6 | |||
| 7 | export interface ComposerAttachmentStripProps { | ||
| 8 | /** Attachment images to display. */ | ||
| 9 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 10 | /** Called when the user taps the remove button on an image. */ | ||
| 11 | readonly onRemove: (imageId: string) => void; | ||
| 12 | /** Called when the user taps on an image thumbnail to preview it. */ | ||
| 13 | readonly onPressImage?: (previewUri: string) => void; | ||
| 14 | /** Image thumbnail size in points. Defaults to 72. */ | ||
| 15 | readonly imageSize?: number; | ||
| 16 | /** Border radius of each image thumbnail. Defaults to 16. */ | ||
| 17 | readonly imageBorderRadius?: number; | ||
| 18 | /** Whether the remove button should sit in its own gutter instead of overlapping the image. */ | ||
| 19 | readonly removeButtonPlacement?: "overlay" | "gutter"; | ||
| 20 | } | ||
| 21 | |||
| 22 | /** | ||
| 23 | * A horizontally-scrollable strip of image attachment thumbnails with remove | ||
| 24 | * buttons. Used by both the thread composer and the new-task draft screen. | ||
| 25 | */ | ||
| 26 | export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { | ||
| 27 | const subtleBg = useThemeColor("--color-subtle"); | ||
| 28 | const size = props.imageSize ?? 72; | ||
| 29 | const radius = props.imageBorderRadius ?? 16; | ||
| 30 | const removeButtonPlacement = props.removeButtonPlacement ?? "overlay"; | ||
| 31 | const removeButtonGutter = removeButtonPlacement === "gutter" ? 10 : 0; | ||
| 32 | |||
| 33 | if (props.attachments.length === 0) { | ||
| 34 | return null; | ||
| 35 | } | ||
| 36 | |||
| 37 | return ( | ||
| 38 | <ScrollView | ||
| 39 | horizontal | ||
| 40 | showsHorizontalScrollIndicator={false} | ||
| 41 | keyboardShouldPersistTaps="always" | ||
| 42 | style={{ flexGrow: 0 }} | ||
| 43 | > | ||
| 44 | <View style={{ flexDirection: "row", gap: 10 }}> | ||
| 45 | {props.attachments.map((image) => ( | ||
| 46 | <View | ||
| 47 | key={image.id} | ||
| 48 | style={{ | ||
| 49 | position: "relative", | ||
| 50 | paddingTop: removeButtonGutter, | ||
| 51 | paddingRight: removeButtonGutter, | ||
| 52 | }} | ||
| 53 | > | ||
| 54 | <Pressable | ||
| 55 | onPress={props.onPressImage ? () => props.onPressImage!(image.previewUri) : undefined} | ||
| 56 | > | ||
| 57 | <Image | ||
| 58 | source={{ uri: image.previewUri }} | ||
| 59 | style={{ | ||
| 60 | width: size, | ||
| 61 | height: size, | ||
| 62 | borderRadius: radius, | ||
| 63 | backgroundColor: subtleBg, | ||
| 64 | }} | ||
| 65 | resizeMode="cover" | ||
| 66 | /> | ||
| 67 | </Pressable> | ||
| 68 | <Pressable | ||
| 69 | style={{ | ||
| 70 | position: "absolute", | ||
| 71 | top: removeButtonPlacement === "gutter" ? 0 : 4, | ||
| 72 | right: removeButtonPlacement === "gutter" ? 0 : 4, | ||
| 73 | width: 22, | ||
| 74 | height: 22, | ||
| 75 | borderRadius: 11, | ||
| 76 | backgroundColor: "rgba(0,0,0,0.55)", | ||
| 77 | alignItems: "center", | ||
| 78 | justifyContent: "center", | ||
| 79 | }} | ||
| 80 | hitSlop={6} | ||
| 81 | onPress={() => props.onRemove(image.id)} | ||
| 82 | > | ||
| 83 | <SymbolView | ||
| 84 | name="xmark" | ||
| 85 | size={9} | ||
| 86 | tintColor="#ffffff" | ||
| 87 | type="monochrome" | ||
| 88 | weight="bold" | ||
| 89 | /> | ||
| 90 | </Pressable> | ||
| 91 | </View> | ||
| 92 | ))} | ||
| 93 | </View> | ||
| 94 | </ScrollView> | ||
| 95 | ); | ||
| 96 | } | ||
apps/mobile/src/components/ControlPill.tsx created+89| ... | @@ -0,0 +1,89 @@ | ||
| 1 | import { MenuView } from "@react-native-menu/menu"; | ||
| 2 | import type { ComponentProps, ReactNode } from "react"; | ||
| 3 | import { Pressable, useColorScheme, View } from "react-native"; | ||
| 4 | import { SymbolView } from "expo-symbols"; | ||
| 5 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 6 | |||
| 7 | import { cn } from "../lib/cn"; | ||
| 8 | import { AppText as Text } from "./AppText"; | ||
| 9 | |||
| 10 | export function ControlPill(props: { | ||
| 11 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; | ||
| 12 | readonly iconNode?: ReactNode; | ||
| 13 | readonly label?: string; | ||
| 14 | readonly accessibilityLabel?: string; | ||
| 15 | readonly onPress?: () => void; | ||
| 16 | readonly variant?: "circle" | "pill" | "primary" | "danger"; | ||
| 17 | readonly disabled?: boolean; | ||
| 18 | }) { | ||
| 19 | const variant = props.variant ?? "circle"; | ||
| 20 | |||
| 21 | const iconColor = useThemeColor("--color-icon"); | ||
| 22 | const iconSubtle = useThemeColor("--color-icon-subtle"); | ||
| 23 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 24 | const dangerFg = useThemeColor("--color-danger-foreground"); | ||
| 25 | const iconTintColor = | ||
| 26 | variant === "primary" | ||
| 27 | ? props.disabled | ||
| 28 | ? iconSubtle | ||
| 29 | : primaryFg | ||
| 30 | : variant === "danger" | ||
| 31 | ? dangerFg | ||
| 32 | : iconColor; | ||
| 33 | |||
| 34 | const isCircle = | ||
| 35 | variant === "circle" || variant === "danger" || (variant === "primary" && !props.label); | ||
| 36 | const containerClassName = cn( | ||
| 37 | isCircle | ||
| 38 | ? "h-11 w-11 items-center justify-center rounded-full" | ||
| 39 | : variant === "primary" | ||
| 40 | ? "h-11 flex-row items-center justify-center gap-2 rounded-full px-5" | ||
| 41 | : "h-11 flex-row items-center justify-center gap-2 rounded-full px-3.5", | ||
| 42 | variant === "primary" | ||
| 43 | ? props.disabled | ||
| 44 | ? "bg-subtle-strong" | ||
| 45 | : "bg-primary" | ||
| 46 | : variant === "danger" | ||
| 47 | ? "bg-danger" | ||
| 48 | : "bg-subtle", | ||
| 49 | ); | ||
| 50 | const labelClassName = cn( | ||
| 51 | "text-center text-[12px] font-t3-bold", | ||
| 52 | variant === "primary" | ||
| 53 | ? props.disabled | ||
| 54 | ? "text-foreground-muted" | ||
| 55 | : "text-primary-foreground" | ||
| 56 | : "", | ||
| 57 | ); | ||
| 58 | |||
| 59 | return ( | ||
| 60 | <Pressable | ||
| 61 | accessibilityLabel={props.accessibilityLabel ?? props.label} | ||
| 62 | accessibilityRole="button" | ||
| 63 | onPress={props.onPress} | ||
| 64 | disabled={props.disabled} | ||
| 65 | className={containerClassName} | ||
| 66 | > | ||
| 67 | {props.iconNode ? ( | ||
| 68 | <View className="h-4 w-4 items-center justify-center">{props.iconNode}</View> | ||
| 69 | ) : props.icon ? ( | ||
| 70 | <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" /> | ||
| 71 | ) : null} | ||
| 72 | {props.label ? <Text className={labelClassName}>{props.label}</Text> : null} | ||
| 73 | </Pressable> | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | |||
| 77 | export function ControlPillMenu( | ||
| 78 | props: Omit<ComponentProps<typeof MenuView>, "children" | "style" | "themeVariant"> & { | ||
| 79 | readonly children: ReactNode; | ||
| 80 | }, | ||
| 81 | ) { | ||
| 82 | const isDarkMode = useColorScheme() === "dark"; | ||
| 83 | |||
| 84 | return ( | ||
| 85 | <MenuView {...props} themeVariant={isDarkMode ? "dark" : "light"}> | ||
| 86 | {props.children} | ||
| 87 | </MenuView> | ||
| 88 | ); | ||
| 89 | } | ||
apps/mobile/src/components/EmptyState.tsx created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import { Pressable, View } from "react-native"; | ||
| 2 | |||
| 3 | import { AppText as Text } from "./AppText"; | ||
| 4 | |||
| 5 | export function EmptyState(props: { | ||
| 6 | readonly title: string; | ||
| 7 | readonly detail: string; | ||
| 8 | readonly actionLabel?: string; | ||
| 9 | readonly onAction?: () => void; | ||
| 10 | }) { | ||
| 11 | return ( | ||
| 12 | <View className="rounded-[22px] border border-border bg-card p-5"> | ||
| 13 | <Text className="font-t3-bold text-lg text-foreground">{props.title}</Text> | ||
| 14 | <Text className="mt-2 font-sans text-sm leading-[21px] text-foreground-muted"> | ||
| 15 | {props.detail} | ||
| 16 | </Text> | ||
| 17 | {props.actionLabel && props.onAction ? ( | ||
| 18 | <Pressable | ||
| 19 | className="mt-4 self-start rounded-full bg-primary px-4 py-2.5 active:opacity-70" | ||
| 20 | onPress={props.onAction} | ||
| 21 | > | ||
| 22 | <Text className="text-[13px] font-t3-bold text-primary-foreground"> | ||
| 23 | {props.actionLabel} | ||
| 24 | </Text> | ||
| 25 | </Pressable> | ||
| 26 | ) : null} | ||
| 27 | </View> | ||
| 28 | ); | ||
| 29 | } | ||
apps/mobile/src/components/ErrorBanner.tsx created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import { View } from "react-native"; | ||
| 2 | |||
| 3 | import { AppText as Text } from "./AppText"; | ||
| 4 | export function ErrorBanner(props: { readonly message: string }) { | ||
| 5 | return ( | ||
| 6 | <View className="rounded-2xl border border-rose-300/70 bg-rose-100/80 px-3.5 py-3 dark:border-rose-400/28 dark:bg-rose-500/12"> | ||
| 7 | <Text className="font-t3-medium text-[13px] leading-[18px] text-rose-700 dark:text-rose-300"> | ||
| 8 | {props.message} | ||
| 9 | </Text> | ||
| 10 | </View> | ||
| 11 | ); | ||
| 12 | } | ||
apps/mobile/src/components/GlassSafeAreaView.tsx created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | import { useColorScheme, View, type StyleProp, type ViewStyle } from "react-native"; | ||
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 4 | |||
| 5 | import { GlassSurface } from "./GlassSurface"; | ||
| 6 | |||
| 7 | export interface GlassSafeAreaViewProps { | ||
| 8 | readonly leftSlot?: ReactNode; | ||
| 9 | readonly centerSlot?: ReactNode; | ||
| 10 | readonly rightSlot?: ReactNode; | ||
| 11 | readonly style?: StyleProp<ViewStyle>; | ||
| 12 | } | ||
| 13 | |||
| 14 | export function GlassSafeAreaView({ | ||
| 15 | leftSlot, | ||
| 16 | centerSlot, | ||
| 17 | rightSlot, | ||
| 18 | style, | ||
| 19 | }: GlassSafeAreaViewProps) { | ||
| 20 | const isDarkMode = useColorScheme() === "dark"; | ||
| 21 | const insets = useSafeAreaInsets(); | ||
| 22 | const headerPaddingTop = insets.top + 16; | ||
| 23 | const surfaceStyle = { | ||
| 24 | borderRadius: 0, | ||
| 25 | backgroundColor: isDarkMode ? "rgba(10,10,10,0.97)" : "rgba(255,255,255,0.97)", | ||
| 26 | borderBottomWidth: 1, | ||
| 27 | borderBottomColor: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)", | ||
| 28 | } as const; | ||
| 29 | |||
| 30 | return ( | ||
| 31 | <View style={[surfaceStyle, style]}> | ||
| 32 | <GlassSurface | ||
| 33 | chrome="none" | ||
| 34 | glassEffectStyle="regular" | ||
| 35 | tintColor={isDarkMode ? "rgba(15,23,42,0.24)" : "rgba(255,255,255,0.18)"} | ||
| 36 | style={{ borderRadius: 0, backgroundColor: "transparent" }} | ||
| 37 | > | ||
| 38 | <View | ||
| 39 | style={{ | ||
| 40 | flexDirection: "row", | ||
| 41 | alignItems: "center", | ||
| 42 | paddingHorizontal: 20, | ||
| 43 | paddingTop: headerPaddingTop, | ||
| 44 | paddingBottom: 16, | ||
| 45 | gap: 10, | ||
| 46 | }} | ||
| 47 | > | ||
| 48 | <View style={{ alignItems: "flex-start", justifyContent: "center" }}>{leftSlot}</View> | ||
| 49 | <View | ||
| 50 | style={{ flex: 1, alignItems: "center", justifyContent: "center", overflow: "hidden" }} | ||
| 51 | > | ||
| 52 | {centerSlot} | ||
| 53 | </View> | ||
| 54 | <View style={{ alignItems: "flex-end", justifyContent: "center" }}>{rightSlot}</View> | ||
| 55 | </View> | ||
| 56 | </GlassSurface> | ||
| 57 | </View> | ||
| 58 | ); | ||
| 59 | } | ||
apps/mobile/src/components/GlassSurface.tsx created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; | ||
| 2 | import type { ReactNode } from "react"; | ||
| 3 | import { Platform, useColorScheme, View, type ViewProps, type ViewStyle } from "react-native"; | ||
| 4 | |||
| 5 | export interface GlassSurfaceProps extends ViewProps { | ||
| 6 | readonly children: ReactNode; | ||
| 7 | readonly glassEffectStyle?: "clear" | "regular" | "none"; | ||
| 8 | readonly tintColor?: string; | ||
| 9 | readonly chrome?: "default" | "none"; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function GlassSurface({ | ||
| 13 | children, | ||
| 14 | glassEffectStyle = "regular", | ||
| 15 | chrome = "default", | ||
| 16 | tintColor, | ||
| 17 | style, | ||
| 18 | ...props | ||
| 19 | }: GlassSurfaceProps) { | ||
| 20 | const isDarkMode = useColorScheme() === "dark"; | ||
| 21 | const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); | ||
| 22 | const surfaceStyle: ViewStyle = { | ||
| 23 | borderRadius: 32, | ||
| 24 | overflow: "hidden", | ||
| 25 | borderWidth: chrome === "none" ? 0 : 1, | ||
| 26 | borderColor: | ||
| 27 | chrome === "none" | ||
| 28 | ? "transparent" | ||
| 29 | : isDarkMode | ||
| 30 | ? "rgba(255,255,255,0.08)" | ||
| 31 | : "rgba(226,232,240,0.9)", | ||
| 32 | backgroundColor: | ||
| 33 | chrome === "none" | ||
| 34 | ? "transparent" | ||
| 35 | : isDarkMode | ||
| 36 | ? "rgba(15,23,42,0.78)" | ||
| 37 | : "rgba(255,255,255,0.72)", | ||
| 38 | shadowColor: chrome === "none" ? "transparent" : "#020617", | ||
| 39 | shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08, | ||
| 40 | shadowRadius: chrome === "none" ? 0 : 28, | ||
| 41 | shadowOffset: | ||
| 42 | chrome === "none" | ||
| 43 | ? { | ||
| 44 | width: 0, | ||
| 45 | height: 0, | ||
| 46 | } | ||
| 47 | : { | ||
| 48 | width: 0, | ||
| 49 | height: 14, | ||
| 50 | }, | ||
| 51 | elevation: chrome === "none" ? 0 : 12, | ||
| 52 | }; | ||
| 53 | |||
| 54 | if (supportsGlass) { | ||
| 55 | return ( | ||
| 56 | <GlassView | ||
| 57 | {...props} | ||
| 58 | glassEffectStyle={glassEffectStyle} | ||
| 59 | tintColor={tintColor ?? (isDarkMode ? "rgba(15,23,42,0.18)" : "rgba(255,255,255,0.18)")} | ||
| 60 | colorScheme={isDarkMode ? "dark" : "light"} | ||
| 61 | style={[surfaceStyle, style]} | ||
| 62 | > | ||
| 63 | {children} | ||
| 64 | </GlassView> | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | |||
| 68 | return ( | ||
| 69 | <View {...props} style={[surfaceStyle, style]}> | ||
| 70 | {children} | ||
| 71 | </View> | ||
| 72 | ); | ||
| 73 | } | ||
apps/mobile/src/components/LoadingScreen.tsx created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native"; | ||
| 2 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 3 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 4 | |||
| 5 | import { AppText as Text } from "./AppText"; | ||
| 6 | import { BrandMark } from "./BrandMark"; | ||
| 7 | |||
| 8 | export function LoadingScreen(props: { readonly message: string }) { | ||
| 9 | const colorScheme = useColorScheme(); | ||
| 10 | const screenBg = useThemeColor("--color-screen"); | ||
| 11 | const insets = useSafeAreaInsets(); | ||
| 12 | |||
| 13 | return ( | ||
| 14 | <View className="flex-1 bg-screen" style={{ paddingTop: insets.top }}> | ||
| 15 | <StatusBar | ||
| 16 | barStyle={colorScheme === "dark" ? "light-content" : "dark-content"} | ||
| 17 | backgroundColor={screenBg as string} | ||
| 18 | translucent | ||
| 19 | /> | ||
| 20 | <View className="flex-1 items-center justify-center gap-5 px-6"> | ||
| 21 | <BrandMark compact /> | ||
| 22 | <ActivityIndicator size="large" /> | ||
| 23 | <Text className="font-t3-bold text-lg text-foreground">{props.message}</Text> | ||
| 24 | </View> | ||
| 25 | </View> | ||
| 26 | ); | ||
| 27 | } | ||
apps/mobile/src/components/ProjectFavicon.tsx created+70| ... | @@ -0,0 +1,70 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { useState } from "react"; | ||
| 3 | import { Image, View } from "react-native"; | ||
| 4 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 5 | |||
| 6 | /* ─── Favicon cache (matches web pattern) ────────────────────────────── */ | ||
| 7 | const loadedFaviconUrls = new Set<string>(); | ||
| 8 | |||
| 9 | /* ─── Component ──────────────────────────────────────────────────────── */ | ||
| 10 | export function ProjectFavicon(props: { | ||
| 11 | readonly size?: number; | ||
| 12 | readonly projectTitle: string; | ||
| 13 | readonly httpBaseUrl?: string | null; | ||
| 14 | readonly workspaceRoot?: string | null; | ||
| 15 | readonly bearerToken?: string | null; | ||
| 16 | }) { | ||
| 17 | const size = props.size ?? 42; | ||
| 18 | const iconMuted = useThemeColor("--color-icon-subtle"); | ||
| 19 | |||
| 20 | const faviconUrl = | ||
| 21 | props.httpBaseUrl && props.workspaceRoot | ||
| 22 | ? `${props.httpBaseUrl}/api/project-favicon?cwd=${encodeURIComponent(props.workspaceRoot)}` | ||
| 23 | : null; | ||
| 24 | |||
| 25 | const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => | ||
| 26 | faviconUrl && loadedFaviconUrls.has(faviconUrl) ? "loaded" : "loading", | ||
| 27 | ); | ||
| 28 | |||
| 29 | const showImage = faviconUrl && status === "loaded"; | ||
| 30 | |||
| 31 | return ( | ||
| 32 | <View | ||
| 33 | style={{ | ||
| 34 | width: size, | ||
| 35 | height: size, | ||
| 36 | alignItems: "center", | ||
| 37 | justifyContent: "center", | ||
| 38 | }} | ||
| 39 | > | ||
| 40 | {/* Folder icon fallback (matches web's FolderIcon) */} | ||
| 41 | {!showImage ? ( | ||
| 42 | <SymbolView name="folder.fill" size={size * 0.78} tintColor={iconMuted} type="monochrome" /> | ||
| 43 | ) : null} | ||
| 44 | |||
| 45 | {/* Favicon image (hidden until loaded) */} | ||
| 46 | {faviconUrl ? ( | ||
| 47 | <Image | ||
| 48 | source={{ | ||
| 49 | uri: faviconUrl, | ||
| 50 | ...(props.bearerToken | ||
| 51 | ? { headers: { Authorization: `Bearer ${props.bearerToken}` } } | ||
| 52 | : {}), | ||
| 53 | }} | ||
| 54 | style={{ | ||
| 55 | width: size, | ||
| 56 | height: size, | ||
| 57 | borderRadius: size * 0.16, | ||
| 58 | ...(showImage ? {} : { position: "absolute" as const, opacity: 0 }), | ||
| 59 | }} | ||
| 60 | resizeMode="contain" | ||
| 61 | onLoad={() => { | ||
| 62 | if (faviconUrl) loadedFaviconUrls.add(faviconUrl); | ||
| 63 | setStatus("loaded"); | ||
| 64 | }} | ||
| 65 | onError={() => setStatus("error")} | ||
| 66 | /> | ||
| 67 | ) : null} | ||
| 68 | </View> | ||
| 69 | ); | ||
| 70 | } | ||
apps/mobile/src/components/ProviderIcon.tsx created+32| ... | @@ -0,0 +1,32 @@ | ||
| 1 | import { useColorScheme } from "react-native"; | ||
| 2 | import { Path, Svg } from "react-native-svg"; | ||
| 3 | |||
| 4 | type ProviderIconProps = { | ||
| 5 | readonly provider: string | null | undefined; | ||
| 6 | readonly size?: number; | ||
| 7 | }; | ||
| 8 | |||
| 9 | export function ProviderIcon(props: ProviderIconProps) { | ||
| 10 | const isDarkMode = useColorScheme() === "dark"; | ||
| 11 | const size = props.size ?? 16; | ||
| 12 | |||
| 13 | if (props.provider === "claudeAgent") { | ||
| 14 | return ( | ||
| 15 | <Svg width={size} height={size} viewBox="0 0 256 257" fill="none"> | ||
| 16 | <Path | ||
| 17 | fill="#D97757" | ||
| 18 | d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z" | ||
| 19 | /> | ||
| 20 | </Svg> | ||
| 21 | ); | ||
| 22 | } | ||
| 23 | |||
| 24 | return ( | ||
| 25 | <Svg width={size} height={size} viewBox="0 0 256 260" fill="none"> | ||
| 26 | <Path | ||
| 27 | fill={isDarkMode ? "#e2e8f0" : "#171717"} | ||
| 28 | d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" | ||
| 29 | /> | ||
| 30 | </Svg> | ||
| 31 | ); | ||
| 32 | } | ||
apps/mobile/src/components/SourceControlIcon.tsx created+99| ... | @@ -0,0 +1,99 @@ | ||
| 1 | import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; | ||
| 2 | |||
| 3 | export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops"; | ||
| 4 | |||
| 5 | export function SourceControlIcon(props: { | ||
| 6 | readonly kind: SourceControlIconKind; | ||
| 7 | readonly size?: number; | ||
| 8 | readonly color?: string; | ||
| 9 | }) { | ||
| 10 | const size = props.size ?? 18; | ||
| 11 | |||
| 12 | switch (props.kind) { | ||
| 13 | case "github": | ||
| 14 | return ( | ||
| 15 | <Svg width={size} height={size} viewBox="0 0 16 16" fill="none"> | ||
| 16 | <Path | ||
| 17 | fillRule="evenodd" | ||
| 18 | clipRule="evenodd" | ||
| 19 | d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.68 7.68 0 0 1 8.02 3.86c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" | ||
| 20 | fill={props.color ?? "#24292F"} | ||
| 21 | /> | ||
| 22 | </Svg> | ||
| 23 | ); | ||
| 24 | case "gitlab": | ||
| 25 | return ( | ||
| 26 | <Svg width={size} height={size} viewBox="0 0 32 32" fill="none"> | ||
| 27 | <Path | ||
| 28 | d="m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z" | ||
| 29 | fill="#E24329" | ||
| 30 | /> | ||
| 31 | <Path | ||
| 32 | d="m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z" | ||
| 33 | fill="#FC6D26" | ||
| 34 | /> | ||
| 35 | <Path | ||
| 36 | d="m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z" | ||
| 37 | fill="#FCA326" | ||
| 38 | /> | ||
| 39 | <Path | ||
| 40 | d="M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z" | ||
| 41 | fill="#FC6D26" | ||
| 42 | /> | ||
| 43 | </Svg> | ||
| 44 | ); | ||
| 45 | case "azure-devops": | ||
| 46 | return ( | ||
| 47 | <Svg width={size} height={size} viewBox="0 0 96 96"> | ||
| 48 | <Defs> | ||
| 49 | <LinearGradient id="azure-a" x1="42.83" x2="15.79" y1="12.69" y2="92.57"> | ||
| 50 | <Stop offset="0" stopColor="#114A8B" /> | ||
| 51 | <Stop offset="1" stopColor="#0669BC" /> | ||
| 52 | </LinearGradient> | ||
| 53 | <LinearGradient id="azure-b" x1="47.84" x2="77.52" y1="10.36" y2="89.44"> | ||
| 54 | <Stop offset="0" stopColor="#3CCBF4" /> | ||
| 55 | <Stop offset="1" stopColor="#2892DF" /> | ||
| 56 | </LinearGradient> | ||
| 57 | </Defs> | ||
| 58 | <Path | ||
| 59 | fill="url(#azure-a)" | ||
| 60 | d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z" | ||
| 61 | /> | ||
| 62 | <Path | ||
| 63 | fill="#0078D4" | ||
| 64 | d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z" | ||
| 65 | /> | ||
| 66 | <Path | ||
| 67 | fill="url(#azure-b)" | ||
| 68 | d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z" | ||
| 69 | /> | ||
| 70 | </Svg> | ||
| 71 | ); | ||
| 72 | case "bitbucket": | ||
| 73 | return ( | ||
| 74 | <Svg width={size} height={size} viewBox="8.4 14.39 2481.29 2231.21"> | ||
| 75 | <Defs> | ||
| 76 | <LinearGradient | ||
| 77 | id="bitbucket-a" | ||
| 78 | x1="945.1094" | ||
| 79 | y1="1524.8389" | ||
| 80 | x2="944.4923" | ||
| 81 | y2="1524.1893" | ||
| 82 | gradientTransform="matrix(1996.6343 0 0 -1480.3047 -1884485.625 2258195)" | ||
| 83 | > | ||
| 84 | <Stop offset="0.18" stopColor="#0052CC" /> | ||
| 85 | <Stop offset="1" stopColor="#2684FF" /> | ||
| 86 | </LinearGradient> | ||
| 87 | </Defs> | ||
| 88 | <Path | ||
| 89 | fill="#2684FF" | ||
| 90 | d="M88.92,14.4C45.02,13.83,8.97,48.96,8.41,92.86c-0.06,4.61,0.28,9.22,1.02,13.77l337.48,2048.72 c8.68,51.75,53.26,89.8,105.74,90.24h1619.03c39.38,0.5,73.19-27.9,79.49-66.78l337.49-2071.78c7.03-43.34-22.41-84.17-65.75-91.2 c-4.55-0.74-9.15-1.08-13.76-1.02L88.92,14.4z M1509.99,1495.09H993.24l-139.92-731h781.89L1509.99,1495.09z" | ||
| 91 | /> | ||
| 92 | <Path | ||
| 93 | fill="url(#bitbucket-a)" | ||
| 94 | d="M2379.27,763.06h-745.5l-125.12,730.42H992.31l-609.67,723.67c19.32,16.71,43.96,26,69.5,26.21h1618.13 c39.35,0.51,73.14-27.88,79.44-66.72L2379.27,763.06z" | ||
| 95 | /> | ||
| 96 | </Svg> | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | } | ||
apps/mobile/src/components/StatusPill.tsx created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import { View } from "react-native"; | ||
| 2 | |||
| 3 | import { AppText as Text } from "./AppText"; | ||
| 4 | import { cn } from "../lib/cn"; | ||
| 5 | |||
| 6 | export interface StatusTone { | ||
| 7 | readonly label: string; | ||
| 8 | readonly pillClassName: string; | ||
| 9 | readonly textClassName: string; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function StatusPill( | ||
| 13 | props: StatusTone & { | ||
| 14 | readonly size?: "default" | "compact"; | ||
| 15 | }, | ||
| 16 | ) { | ||
| 17 | const size = props.size ?? "default"; | ||
| 18 | return ( | ||
| 19 | <View | ||
| 20 | className={cn( | ||
| 21 | "rounded-full", | ||
| 22 | size === "compact" ? "px-2.5 py-1" : "px-3 py-1.5", | ||
| 23 | props.pillClassName, | ||
| 24 | )} | ||
| 25 | > | ||
| 26 | <Text | ||
| 27 | className={cn( | ||
| 28 | "font-t3-bold", | ||
| 29 | size === "compact" ? "text-[11px]" : "text-xs", | ||
| 30 | props.textClassName, | ||
| 31 | )} | ||
| 32 | > | ||
| 33 | {props.label} | ||
| 34 | </Text> | ||
| 35 | </View> | ||
| 36 | ); | ||
| 37 | } | ||
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx created+190| ... | @@ -0,0 +1,190 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { useCallback, useState } from "react"; | ||
| 4 | import { Pressable, View } from "react-native"; | ||
| 5 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; | ||
| 6 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 7 | |||
| 8 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 9 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | ||
| 10 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; | ||
| 11 | |||
| 12 | function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { | ||
| 13 | if (environment.connectionError) { | ||
| 14 | return null; | ||
| 15 | } | ||
| 16 | |||
| 17 | switch (environment.connectionState) { | ||
| 18 | case "ready": | ||
| 19 | return "Connected"; | ||
| 20 | case "connecting": | ||
| 21 | return "Connecting"; | ||
| 22 | case "reconnecting": | ||
| 23 | return "Reconnecting"; | ||
| 24 | case "disconnected": | ||
| 25 | return null; | ||
| 26 | case "idle": | ||
| 27 | return null; | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | export function ConnectionEnvironmentRow(props: { | ||
| 32 | readonly environment: ConnectedEnvironmentSummary; | ||
| 33 | readonly expanded: boolean; | ||
| 34 | readonly onToggle: () => void; | ||
| 35 | readonly onReconnect: (environmentId: EnvironmentId) => void; | ||
| 36 | readonly onRemove: (environmentId: EnvironmentId) => void; | ||
| 37 | readonly onUpdate: ( | ||
| 38 | environmentId: EnvironmentId, | ||
| 39 | updates: { readonly label: string; readonly displayUrl: string }, | ||
| 40 | ) => void; | ||
| 41 | }) { | ||
| 42 | const [label, setLabel] = useState(props.environment.environmentLabel); | ||
| 43 | const [url, setUrl] = useState(props.environment.displayUrl); | ||
| 44 | |||
| 45 | const mutedColor = useThemeColor("--color-icon-subtle"); | ||
| 46 | const placeholderColor = useThemeColor("--color-placeholder"); | ||
| 47 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 48 | const dangerFg = useThemeColor("--color-danger-foreground"); | ||
| 49 | const statusLabel = connectionStatusLabel(props.environment); | ||
| 50 | |||
| 51 | const handleSave = useCallback(() => { | ||
| 52 | props.onUpdate(props.environment.environmentId, { | ||
| 53 | label: label.trim(), | ||
| 54 | displayUrl: url.trim(), | ||
| 55 | }); | ||
| 56 | props.onToggle(); | ||
| 57 | }, [label, url, props]); | ||
| 58 | |||
| 59 | return ( | ||
| 60 | <Animated.View layout={LinearTransition.duration(250)} className="bg-card"> | ||
| 61 | <Pressable | ||
| 62 | className="flex-row items-center gap-3 px-4 py-3.5 active:opacity-70" | ||
| 63 | onPress={props.onToggle} | ||
| 64 | > | ||
| 65 | <ConnectionStatusDot | ||
| 66 | state={props.environment.connectionState} | ||
| 67 | pulse={ | ||
| 68 | props.environment.connectionState === "connecting" || | ||
| 69 | props.environment.connectionState === "reconnecting" | ||
| 70 | } | ||
| 71 | size={8} | ||
| 72 | /> | ||
| 73 | |||
| 74 | <View className="flex-1 gap-0.5"> | ||
| 75 | <Text | ||
| 76 | className="text-[16px] font-t3-bold leading-[21px] text-foreground" | ||
| 77 | numberOfLines={1} | ||
| 78 | > | ||
| 79 | {props.environment.environmentLabel} | ||
| 80 | </Text> | ||
| 81 | <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}> | ||
| 82 | {props.environment.displayUrl} | ||
| 83 | </Text> | ||
| 84 | {statusLabel ? ( | ||
| 85 | <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}> | ||
| 86 | {statusLabel} | ||
| 87 | </Text> | ||
| 88 | ) : null} | ||
| 89 | {props.environment.connectionError ? ( | ||
| 90 | <Text | ||
| 91 | className="text-[12px] leading-[16px] text-rose-500 dark:text-rose-400" | ||
| 92 | numberOfLines={2} | ||
| 93 | > | ||
| 94 | {props.environment.connectionError} | ||
| 95 | </Text> | ||
| 96 | ) : null} | ||
| 97 | </View> | ||
| 98 | |||
| 99 | <SymbolView | ||
| 100 | name="chevron.down" | ||
| 101 | size={12} | ||
| 102 | tintColor={mutedColor} | ||
| 103 | type="monochrome" | ||
| 104 | style={{ | ||
| 105 | transform: [{ rotate: props.expanded ? "180deg" : "0deg" }], | ||
| 106 | }} | ||
| 107 | /> | ||
| 108 | </Pressable> | ||
| 109 | |||
| 110 | {props.expanded ? ( | ||
| 111 | <Animated.View | ||
| 112 | entering={FadeIn.duration(200)} | ||
| 113 | exiting={FadeOut.duration(150)} | ||
| 114 | className="gap-3 px-4 pb-4" | ||
| 115 | > | ||
| 116 | <View className="gap-1.5"> | ||
| 117 | <Text | ||
| 118 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" | ||
| 119 | style={{ letterSpacing: 0.8 }} | ||
| 120 | > | ||
| 121 | Label | ||
| 122 | </Text> | ||
| 123 | <TextInput | ||
| 124 | autoCapitalize="words" | ||
| 125 | autoCorrect={false} | ||
| 126 | placeholder="My MacBook" | ||
| 127 | placeholderTextColor={placeholderColor} | ||
| 128 | value={label} | ||
| 129 | onChangeText={setLabel} | ||
| 130 | className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" | ||
| 131 | /> | ||
| 132 | </View> | ||
| 133 | |||
| 134 | <View className="gap-1.5"> | ||
| 135 | <Text | ||
| 136 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" | ||
| 137 | style={{ letterSpacing: 0.8 }} | ||
| 138 | > | ||
| 139 | URL | ||
| 140 | </Text> | ||
| 141 | <TextInput | ||
| 142 | autoCapitalize="none" | ||
| 143 | autoCorrect={false} | ||
| 144 | keyboardType="url" | ||
| 145 | placeholder="192.168.1.100:8080" | ||
| 146 | placeholderTextColor={placeholderColor} | ||
| 147 | value={url} | ||
| 148 | onChangeText={setUrl} | ||
| 149 | className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" | ||
| 150 | /> | ||
| 151 | </View> | ||
| 152 | |||
| 153 | <View className="flex-row gap-2"> | ||
| 154 | <Pressable | ||
| 155 | className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] bg-primary px-3.5 py-2.5 active:opacity-70" | ||
| 156 | onPress={handleSave} | ||
| 157 | > | ||
| 158 | <SymbolView name="checkmark" size={13} tintColor={primaryFg} type="monochrome" /> | ||
| 159 | <Text | ||
| 160 | className="text-[12px] font-t3-bold uppercase text-primary-foreground" | ||
| 161 | style={{ letterSpacing: 0.8 }} | ||
| 162 | > | ||
| 163 | Save | ||
| 164 | </Text> | ||
| 165 | </Pressable> | ||
| 166 | |||
| 167 | <Pressable | ||
| 168 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70" | ||
| 169 | onPress={() => props.onReconnect(props.environment.environmentId)} | ||
| 170 | > | ||
| 171 | <SymbolView | ||
| 172 | name="arrow.clockwise" | ||
| 173 | size={14} | ||
| 174 | tintColor={mutedColor} | ||
| 175 | type="monochrome" | ||
| 176 | /> | ||
| 177 | </Pressable> | ||
| 178 | |||
| 179 | <Pressable | ||
| 180 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70" | ||
| 181 | onPress={() => props.onRemove(props.environment.environmentId)} | ||
| 182 | > | ||
| 183 | <SymbolView name="trash" size={14} tintColor={dangerFg} type="monochrome" /> | ||
| 184 | </Pressable> | ||
| 185 | </View> | ||
| 186 | </Animated.View> | ||
| 187 | ) : null} | ||
| 188 | </Animated.View> | ||
| 189 | ); | ||
| 190 | } | ||
apps/mobile/src/features/connection/ConnectionSheetButton.tsx created+114| ... | @@ -0,0 +1,114 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { Platform, Pressable } from "react-native"; | ||
| 3 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 4 | |||
| 5 | import { AppText as Text } from "../../components/AppText"; | ||
| 6 | import { cn } from "../../lib/cn"; | ||
| 7 | |||
| 8 | const CARD_SHADOW = Platform.select({ | ||
| 9 | ios: { | ||
| 10 | shadowColor: "rgba(23,23,23,0.08)", | ||
| 11 | shadowOffset: { width: 0, height: 4 }, | ||
| 12 | shadowOpacity: 1, | ||
| 13 | shadowRadius: 16, | ||
| 14 | }, | ||
| 15 | android: { elevation: 3 }, | ||
| 16 | }); | ||
| 17 | |||
| 18 | const CARD_SHADOW_DARK = Platform.select({ | ||
| 19 | ios: { | ||
| 20 | shadowColor: "#000", | ||
| 21 | shadowOffset: { width: 0, height: 2 }, | ||
| 22 | shadowOpacity: 0.18, | ||
| 23 | shadowRadius: 8, | ||
| 24 | }, | ||
| 25 | android: { elevation: 4 }, | ||
| 26 | }); | ||
| 27 | |||
| 28 | export { CARD_SHADOW, CARD_SHADOW_DARK }; | ||
| 29 | |||
| 30 | export function ConnectionSheetButton(props: { | ||
| 31 | readonly icon: React.ComponentProps<typeof SymbolView>["name"]; | ||
| 32 | readonly label: string; | ||
| 33 | readonly disabled?: boolean; | ||
| 34 | readonly tone?: "primary" | "secondary" | "danger"; | ||
| 35 | readonly compact?: boolean; | ||
| 36 | readonly onPress: () => void; | ||
| 37 | }) { | ||
| 38 | const tone = props.tone ?? "secondary"; | ||
| 39 | |||
| 40 | const primaryBg = useThemeColor("--color-primary"); | ||
| 41 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 42 | const dangerBg = useThemeColor("--color-danger"); | ||
| 43 | const dangerBorderColor = useThemeColor("--color-danger-border"); | ||
| 44 | const dangerFg = useThemeColor("--color-danger-foreground"); | ||
| 45 | const secondaryBg = useThemeColor("--color-secondary"); | ||
| 46 | const secondaryFg = useThemeColor("--color-secondary-foreground"); | ||
| 47 | const borderColor = useThemeColor("--color-border"); | ||
| 48 | |||
| 49 | const colors = | ||
| 50 | tone === "primary" | ||
| 51 | ? { | ||
| 52 | backgroundColor: primaryBg, | ||
| 53 | borderColor: "transparent", | ||
| 54 | textColor: primaryFg, | ||
| 55 | } | ||
| 56 | : tone === "danger" | ||
| 57 | ? { | ||
| 58 | backgroundColor: dangerBg, | ||
| 59 | borderColor: dangerBorderColor, | ||
| 60 | textColor: dangerFg, | ||
| 61 | } | ||
| 62 | : { | ||
| 63 | backgroundColor: secondaryBg, | ||
| 64 | borderColor: borderColor, | ||
| 65 | textColor: secondaryFg, | ||
| 66 | }; | ||
| 67 | |||
| 68 | const primaryShadow = | ||
| 69 | tone === "primary" | ||
| 70 | ? Platform.select({ | ||
| 71 | ios: { | ||
| 72 | shadowColor: "#000", | ||
| 73 | shadowOffset: { width: 0, height: 3 }, | ||
| 74 | shadowOpacity: 0.14, | ||
| 75 | shadowRadius: 6, | ||
| 76 | }, | ||
| 77 | android: { elevation: 3 }, | ||
| 78 | }) | ||
| 79 | : undefined; | ||
| 80 | |||
| 81 | return ( | ||
| 82 | <Pressable | ||
| 83 | className={cn( | ||
| 84 | props.compact | ||
| 85 | ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5" | ||
| 86 | : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3", | ||
| 87 | )} | ||
| 88 | disabled={props.disabled} | ||
| 89 | onPress={props.onPress} | ||
| 90 | style={[ | ||
| 91 | { | ||
| 92 | backgroundColor: colors.backgroundColor, | ||
| 93 | borderWidth: tone === "primary" ? 0 : 1, | ||
| 94 | borderColor: colors.borderColor, | ||
| 95 | opacity: props.disabled ? 0.5 : 1, | ||
| 96 | }, | ||
| 97 | primaryShadow, | ||
| 98 | ]} | ||
| 99 | > | ||
| 100 | <SymbolView | ||
| 101 | name={props.icon} | ||
| 102 | size={props.compact ? 13 : 14} | ||
| 103 | tintColor={colors.textColor} | ||
| 104 | type="monochrome" | ||
| 105 | /> | ||
| 106 | <Text | ||
| 107 | className="text-[12px] font-t3-bold uppercase" | ||
| 108 | style={{ color: colors.textColor, letterSpacing: 0.8 }} | ||
| 109 | > | ||
| 110 | {props.label} | ||
| 111 | </Text> | ||
| 112 | </Pressable> | ||
| 113 | ); | ||
| 114 | } | ||
apps/mobile/src/features/connection/ConnectionStatusDot.tsx created+112| ... | @@ -0,0 +1,112 @@ | ||
| 1 | import { useEffect } from "react"; | ||
| 2 | import { View } from "react-native"; | ||
| 3 | import Animated, { | ||
| 4 | cancelAnimation, | ||
| 5 | Easing, | ||
| 6 | useAnimatedStyle, | ||
| 7 | useSharedValue, | ||
| 8 | withRepeat, | ||
| 9 | withTiming, | ||
| 10 | } from "react-native-reanimated"; | ||
| 11 | |||
| 12 | import type { RemoteClientConnectionState } from "../../lib/connection"; | ||
| 13 | |||
| 14 | function statusDotTone(state: RemoteClientConnectionState): { | ||
| 15 | readonly dotColor: string; | ||
| 16 | readonly haloColor: string; | ||
| 17 | } { | ||
| 18 | switch (state) { | ||
| 19 | case "ready": | ||
| 20 | return { | ||
| 21 | dotColor: "#34d399", | ||
| 22 | haloColor: "rgba(52,211,153,0.48)", | ||
| 23 | }; | ||
| 24 | case "connecting": | ||
| 25 | case "reconnecting": | ||
| 26 | return { | ||
| 27 | dotColor: "#f59e0b", | ||
| 28 | haloColor: "rgba(245,158,11,0.5)", | ||
| 29 | }; | ||
| 30 | case "idle": | ||
| 31 | case "disconnected": | ||
| 32 | return { | ||
| 33 | dotColor: "#ef4444", | ||
| 34 | haloColor: "rgba(239,68,68,0.48)", | ||
| 35 | }; | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | function usePulseAnimation(pulse: boolean) { | ||
| 40 | const pulseProgress = useSharedValue(0); | ||
| 41 | |||
| 42 | useEffect(() => { | ||
| 43 | if (pulse) { | ||
| 44 | pulseProgress.value = withRepeat( | ||
| 45 | withTiming(1, { | ||
| 46 | duration: 1100, | ||
| 47 | easing: Easing.out(Easing.cubic), | ||
| 48 | }), | ||
| 49 | -1, | ||
| 50 | false, | ||
| 51 | ); | ||
| 52 | return; | ||
| 53 | } | ||
| 54 | |||
| 55 | cancelAnimation(pulseProgress); | ||
| 56 | pulseProgress.value = withTiming(0, { | ||
| 57 | duration: 180, | ||
| 58 | easing: Easing.out(Easing.quad), | ||
| 59 | }); | ||
| 60 | }, [pulse, pulseProgress]); | ||
| 61 | |||
| 62 | return pulseProgress; | ||
| 63 | } | ||
| 64 | |||
| 65 | export function ConnectionStatusDot(props: { | ||
| 66 | readonly state: RemoteClientConnectionState; | ||
| 67 | readonly pulse: boolean; | ||
| 68 | readonly size?: number; | ||
| 69 | }) { | ||
| 70 | const pulseProgress = usePulseAnimation(props.pulse); | ||
| 71 | const tone = statusDotTone(props.state); | ||
| 72 | const dotSize = props.size ?? 10; | ||
| 73 | const haloSize = dotSize + 4; | ||
| 74 | const containerSize = haloSize + 4; | ||
| 75 | |||
| 76 | const haloStyle = useAnimatedStyle(() => ({ | ||
| 77 | opacity: props.pulse ? 0.14 + (1 - pulseProgress.value) * 0.3 : 0, | ||
| 78 | transform: [{ scale: 0.78 + pulseProgress.value * 1.16 }], | ||
| 79 | })); | ||
| 80 | |||
| 81 | return ( | ||
| 82 | <View | ||
| 83 | style={{ | ||
| 84 | width: containerSize, | ||
| 85 | height: containerSize, | ||
| 86 | alignItems: "center", | ||
| 87 | justifyContent: "center", | ||
| 88 | }} | ||
| 89 | > | ||
| 90 | <Animated.View | ||
| 91 | style={[ | ||
| 92 | haloStyle, | ||
| 93 | { | ||
| 94 | position: "absolute", | ||
| 95 | width: haloSize, | ||
| 96 | height: haloSize, | ||
| 97 | borderRadius: haloSize / 2, | ||
| 98 | backgroundColor: tone.haloColor, | ||
| 99 | }, | ||
| 100 | ]} | ||
| 101 | /> | ||
| 102 | <View | ||
| 103 | style={{ | ||
| 104 | width: dotSize, | ||
| 105 | height: dotSize, | ||
| 106 | borderRadius: dotSize / 2, | ||
| 107 | backgroundColor: tone.dotColor, | ||
| 108 | }} | ||
| 109 | /> | ||
| 110 | </View> | ||
| 111 | ); | ||
| 112 | } | ||
apps/mobile/src/features/connection/connectionTone.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import type { StatusTone } from "../../components/StatusPill"; | ||
| 2 | import type { RemoteClientConnectionState } from "../../lib/connection"; | ||
| 3 | |||
| 4 | export function connectionTone(state: RemoteClientConnectionState): StatusTone { | ||
| 5 | switch (state) { | ||
| 6 | case "ready": | ||
| 7 | return { | ||
| 8 | label: "Connected", | ||
| 9 | pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", | ||
| 10 | textClassName: "text-emerald-700 dark:text-emerald-300", | ||
| 11 | }; | ||
| 12 | case "reconnecting": | ||
| 13 | return { | ||
| 14 | label: "Reconnecting", | ||
| 15 | pillClassName: "bg-amber-500/12 dark:bg-amber-500/16", | ||
| 16 | textClassName: "text-amber-700 dark:text-amber-300", | ||
| 17 | }; | ||
| 18 | case "connecting": | ||
| 19 | return { | ||
| 20 | label: "Connecting", | ||
| 21 | pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", | ||
| 22 | textClassName: "text-sky-700 dark:text-sky-300", | ||
| 23 | }; | ||
| 24 | case "disconnected": | ||
| 25 | return { | ||
| 26 | label: "Disconnected", | ||
| 27 | pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", | ||
| 28 | textClassName: "text-rose-700 dark:text-rose-300", | ||
| 29 | }; | ||
| 30 | case "idle": | ||
| 31 | return { | ||
| 32 | label: "Idle", | ||
| 33 | pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", | ||
| 34 | textClassName: "text-neutral-600 dark:text-neutral-300", | ||
| 35 | }; | ||
| 36 | } | ||
| 37 | } | ||
apps/mobile/src/features/connection/pairing.test.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; | ||
| 4 | |||
| 5 | describe("extractPairingUrlFromQrPayload", () => { | ||
| 6 | it("trims raw pairing urls from qr payloads", () => { | ||
| 7 | expect( | ||
| 8 | extractPairingUrlFromQrPayload(" https://remote.example.com/pair#token=pairing-token "), | ||
| 9 | ).toBe("https://remote.example.com/pair#token=pairing-token"); | ||
| 10 | }); | ||
| 11 | |||
| 12 | it("unwraps mobile deep links that carry an encoded pairing url", () => { | ||
| 13 | expect( | ||
| 14 | extractPairingUrlFromQrPayload( | ||
| 15 | "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", | ||
| 16 | ), | ||
| 17 | ).toBe("https://remote.example.com/pair#token=pairing-token"); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("rejects empty qr payloads", () => { | ||
| 21 | expect(() => extractPairingUrlFromQrPayload(" ")).toThrow( | ||
| 22 | "Scanned QR code did not contain a pairing URL.", | ||
| 23 | ); | ||
| 24 | }); | ||
| 25 | }); | ||
| 26 | |||
| 27 | describe("parsePairingUrl", () => { | ||
| 28 | it("reads hosted pairing links into backend host fields", () => { | ||
| 29 | expect( | ||
| 30 | parsePairingUrl( | ||
| 31 | "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%2F#token=pairing-token", | ||
| 32 | ), | ||
| 33 | ).toEqual({ | ||
| 34 | host: "https://desktop.tailnet.ts.net", | ||
| 35 | code: "pairing-token", | ||
| 36 | }); | ||
| 37 | }); | ||
| 38 | }); | ||
apps/mobile/src/features/connection/pairing.ts created+67| ... | @@ -0,0 +1,67 @@ | ||
| 1 | import { readHostedPairingRequest } from "@t3tools/shared/remote"; | ||
| 2 | |||
| 3 | const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; | ||
| 4 | |||
| 5 | export function buildPairingUrl(host: string, code: string): string { | ||
| 6 | const h = host.trim(); | ||
| 7 | const c = code.trim(); | ||
| 8 | if (!h) return ""; | ||
| 9 | if (!c) return h; | ||
| 10 | |||
| 11 | try { | ||
| 12 | const url = new URL(h.includes("://") ? h : `https://${h}`); | ||
| 13 | url.hash = new URLSearchParams([["token", c]]).toString(); | ||
| 14 | return url.toString(); | ||
| 15 | } catch { | ||
| 16 | return `${h}#token=${c}`; | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | export function parsePairingUrl(url: string): { host: string; code: string } { | ||
| 21 | const trimmed = url.trim(); | ||
| 22 | if (!trimmed) return { host: "", code: "" }; | ||
| 23 | |||
| 24 | try { | ||
| 25 | const parsed = new URL(trimmed); | ||
| 26 | const hostedPairingRequest = readHostedPairingRequest(parsed); | ||
| 27 | if (hostedPairingRequest) { | ||
| 28 | return { | ||
| 29 | host: hostedPairingRequest.host.replace(/\/$/, ""), | ||
| 30 | code: hostedPairingRequest.token, | ||
| 31 | }; | ||
| 32 | } | ||
| 33 | |||
| 34 | const hashParams = new URLSearchParams(parsed.hash.slice(1)); | ||
| 35 | const hashToken = hashParams.get("token"); | ||
| 36 | const queryToken = parsed.searchParams.get("token"); | ||
| 37 | const code = hashToken || queryToken || ""; | ||
| 38 | |||
| 39 | parsed.hash = ""; | ||
| 40 | parsed.search = ""; | ||
| 41 | parsed.pathname = "/"; | ||
| 42 | return { host: parsed.toString().replace(/\/$/, ""), code }; | ||
| 43 | } catch { | ||
| 44 | return { host: trimmed, code: "" }; | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | export function extractPairingUrlFromQrPayload(payload: string): string { | ||
| 49 | const trimmed = payload.trim(); | ||
| 50 | if (!trimmed) { | ||
| 51 | throw new Error("Scanned QR code did not contain a pairing URL."); | ||
| 52 | } | ||
| 53 | |||
| 54 | try { | ||
| 55 | const url = new URL(trimmed); | ||
| 56 | if (url.protocol === "t3code:") { | ||
| 57 | const pairingUrl = url.searchParams.get(MOBILE_PAIRING_URL_PARAM)?.trim() ?? ""; | ||
| 58 | if (pairingUrl.length > 0) { | ||
| 59 | return pairingUrl; | ||
| 60 | } | ||
| 61 | } | ||
| 62 | } catch { | ||
| 63 | // Treat non-URL payloads as raw pairing-url text so the normal input validation can decide. | ||
| 64 | } | ||
| 65 | |||
| 66 | return trimmed; | ||
| 67 | } | ||
apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts created+405| ... | @@ -0,0 +1,405 @@ | ||
| 1 | import { createHighlighterCore, type HighlighterCore } from "@shikijs/core"; | ||
| 2 | import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript"; | ||
| 3 | import bashLanguage from "@shikijs/langs/bash"; | ||
| 4 | import diffLanguage from "@shikijs/langs/diff"; | ||
| 5 | import javascriptLanguage from "@shikijs/langs/javascript"; | ||
| 6 | import jsonLanguage from "@shikijs/langs/json"; | ||
| 7 | import jsxLanguage from "@shikijs/langs/jsx"; | ||
| 8 | import tsxLanguage from "@shikijs/langs/tsx"; | ||
| 9 | import typescriptLanguage from "@shikijs/langs/typescript"; | ||
| 10 | import yamlLanguage from "@shikijs/langs/yaml"; | ||
| 11 | |||
| 12 | import type { NativeReviewDiffFile, NativeReviewDiffLanguage } from "./nativeReviewDiffTypes"; | ||
| 13 | import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewDiffSurface"; | ||
| 14 | |||
| 15 | export type NativeReviewDiffHighlightScheme = "light" | "dark"; | ||
| 16 | export type NativeReviewDiffHighlightEngine = "native" | "javascript"; | ||
| 17 | |||
| 18 | export interface NativeReviewDiffHighlighterHandle { | ||
| 19 | readonly engine: NativeReviewDiffHighlightEngine; | ||
| 20 | readonly tokenize: ( | ||
| 21 | code: string, | ||
| 22 | options: { readonly lang: NativeReviewDiffLanguage; readonly theme: string }, | ||
| 23 | ) => ReadonlyArray<ReadonlyArray<NativeReviewDiffToken>>; | ||
| 24 | } | ||
| 25 | |||
| 26 | interface NativeReviewDiffLineRow extends NativeReviewDiffRow { | ||
| 27 | readonly kind: "line"; | ||
| 28 | readonly fileId: string; | ||
| 29 | readonly content: string; | ||
| 30 | } | ||
| 31 | |||
| 32 | export interface NativeReviewDiffTokenChunk { | ||
| 33 | readonly chunkIndex: number; | ||
| 34 | readonly fileId: string; | ||
| 35 | readonly filePath: string; | ||
| 36 | readonly language: NativeReviewDiffLanguage; | ||
| 37 | readonly lineCount: number; | ||
| 38 | readonly durationMs: number; | ||
| 39 | readonly tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>>; | ||
| 40 | } | ||
| 41 | |||
| 42 | export interface StreamNativeReviewDiffTokenInput { | ||
| 43 | readonly rows: ReadonlyArray<NativeReviewDiffRow>; | ||
| 44 | readonly files: ReadonlyArray<NativeReviewDiffFile>; | ||
| 45 | readonly scheme: NativeReviewDiffHighlightScheme; | ||
| 46 | readonly engine?: NativeReviewDiffHighlightEngine; | ||
| 47 | readonly chunkSize?: number; | ||
| 48 | readonly signal?: AbortSignal; | ||
| 49 | readonly onChunk: (chunk: NativeReviewDiffTokenChunk) => void; | ||
| 50 | } | ||
| 51 | |||
| 52 | export interface HighlightNativeReviewDiffVisibleRowsInput { | ||
| 53 | readonly rows: ReadonlyArray<NativeReviewDiffRow>; | ||
| 54 | readonly files: ReadonlyArray<NativeReviewDiffFile>; | ||
| 55 | readonly scheme: NativeReviewDiffHighlightScheme; | ||
| 56 | readonly engine?: NativeReviewDiffHighlightEngine; | ||
| 57 | readonly firstRowIndex: number; | ||
| 58 | readonly lastRowIndex: number; | ||
| 59 | readonly overscanRows?: number; | ||
| 60 | readonly maxRows?: number; | ||
| 61 | readonly alreadyHighlightedRowIds?: ReadonlySet<string>; | ||
| 62 | readonly signal?: AbortSignal; | ||
| 63 | } | ||
| 64 | |||
| 65 | const NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE = 500; | ||
| 66 | const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160; | ||
| 67 | const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360; | ||
| 68 | |||
| 69 | const NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME = { | ||
| 70 | dark: "t3-pierre-dark", | ||
| 71 | light: "t3-pierre-light", | ||
| 72 | } as const; | ||
| 73 | |||
| 74 | const PIERRE_LIGHT_SHIKI_THEME = { | ||
| 75 | name: NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME.light, | ||
| 76 | type: "light" as const, | ||
| 77 | fg: "#070707", | ||
| 78 | bg: "#ffffff", | ||
| 79 | settings: [ | ||
| 80 | { settings: { foreground: "#070707", background: "#ffffff" } }, | ||
| 81 | { scope: "comment, punctuation.definition.comment", settings: { foreground: "#84848A" } }, | ||
| 82 | { | ||
| 83 | scope: "keyword, storage, storage.type, keyword.operator.expression", | ||
| 84 | settings: { foreground: "#FC2B73" }, | ||
| 85 | }, | ||
| 86 | { | ||
| 87 | scope: "entity.name.function, support.function, meta.function-call", | ||
| 88 | settings: { foreground: "#7B43F8" }, | ||
| 89 | }, | ||
| 90 | { | ||
| 91 | scope: "entity.name.type, support.type, support.class", | ||
| 92 | settings: { foreground: "#C635E4" }, | ||
| 93 | }, | ||
| 94 | { | ||
| 95 | scope: "string, constant.character, punctuation.definition.string", | ||
| 96 | settings: { foreground: "#199F43" }, | ||
| 97 | }, | ||
| 98 | { | ||
| 99 | scope: "constant.numeric, constant.language, constant.other", | ||
| 100 | settings: { foreground: "#1CA1C7" }, | ||
| 101 | }, | ||
| 102 | { | ||
| 103 | scope: "variable.parameter, variable.other.readwrite, meta.object-literal.key", | ||
| 104 | settings: { foreground: "#D47628" }, | ||
| 105 | }, | ||
| 106 | { | ||
| 107 | scope: "entity.name.tag, support.class.component", | ||
| 108 | settings: { foreground: "#199F43" }, | ||
| 109 | }, | ||
| 110 | { | ||
| 111 | scope: "punctuation, meta.brace, meta.delimiter", | ||
| 112 | settings: { foreground: "#79797F" }, | ||
| 113 | }, | ||
| 114 | { scope: "invalid", settings: { foreground: "#D52C36" } }, | ||
| 115 | ], | ||
| 116 | }; | ||
| 117 | |||
| 118 | const PIERRE_DARK_SHIKI_THEME = { | ||
| 119 | name: NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME.dark, | ||
| 120 | type: "dark" as const, | ||
| 121 | fg: "#adadb1", | ||
| 122 | bg: "#0a0a0a", | ||
| 123 | settings: [ | ||
| 124 | { settings: { foreground: "#adadb1", background: "#0a0a0a" } }, | ||
| 125 | { scope: "comment, punctuation.definition.comment", settings: { foreground: "#84848A" } }, | ||
| 126 | { | ||
| 127 | scope: "keyword, storage, storage.type, keyword.operator.expression", | ||
| 128 | settings: { foreground: "#FF678D" }, | ||
| 129 | }, | ||
| 130 | { | ||
| 131 | scope: "entity.name.function, support.function, meta.function-call", | ||
| 132 | settings: { foreground: "#9D6AFB" }, | ||
| 133 | }, | ||
| 134 | { | ||
| 135 | scope: "entity.name.type, support.type, support.class", | ||
| 136 | settings: { foreground: "#D568EA" }, | ||
| 137 | }, | ||
| 138 | { | ||
| 139 | scope: "string, constant.character, punctuation.definition.string", | ||
| 140 | settings: { foreground: "#5ECC71" }, | ||
| 141 | }, | ||
| 142 | { | ||
| 143 | scope: "constant.numeric, constant.language, constant.other", | ||
| 144 | settings: { foreground: "#68CDF2" }, | ||
| 145 | }, | ||
| 146 | { | ||
| 147 | scope: "variable.parameter, variable.other.readwrite, meta.object-literal.key", | ||
| 148 | settings: { foreground: "#FFA359" }, | ||
| 149 | }, | ||
| 150 | { | ||
| 151 | scope: "entity.name.tag, support.class.component", | ||
| 152 | settings: { foreground: "#5ECC71" }, | ||
| 153 | }, | ||
| 154 | { | ||
| 155 | scope: "punctuation, meta.brace, meta.delimiter", | ||
| 156 | settings: { foreground: "#79797F" }, | ||
| 157 | }, | ||
| 158 | { scope: "invalid", settings: { foreground: "#FF6762" } }, | ||
| 159 | ], | ||
| 160 | }; | ||
| 161 | |||
| 162 | const NATIVE_REVIEW_DIFF_SHIKI_THEMES = [ | ||
| 163 | PIERRE_LIGHT_SHIKI_THEME, | ||
| 164 | PIERRE_DARK_SHIKI_THEME, | ||
| 165 | ] satisfies Parameters<typeof createHighlighterCore>[0]["themes"]; | ||
| 166 | |||
| 167 | const NATIVE_REVIEW_DIFF_LANGUAGES = [ | ||
| 168 | bashLanguage, | ||
| 169 | diffLanguage, | ||
| 170 | javascriptLanguage, | ||
| 171 | jsonLanguage, | ||
| 172 | jsxLanguage, | ||
| 173 | tsxLanguage, | ||
| 174 | typescriptLanguage, | ||
| 175 | yamlLanguage, | ||
| 176 | ] satisfies Parameters<typeof createHighlighterCore>[0]["langs"]; | ||
| 177 | |||
| 178 | let nativeHighlighterPromise: Promise<NativeReviewDiffHighlighterHandle> | null = null; | ||
| 179 | let javascriptHighlighterPromise: Promise<NativeReviewDiffHighlighterHandle> | null = null; | ||
| 180 | |||
| 181 | function waitForNextFrame(): Promise<void> { | ||
| 182 | return new Promise((resolve) => setTimeout(resolve, 0)); | ||
| 183 | } | ||
| 184 | |||
| 185 | function normalizeTokens( | ||
| 186 | tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>, | ||
| 187 | ): ReadonlyArray<ReadonlyArray<NativeReviewDiffToken>> { | ||
| 188 | return tokenLines.map((line) => | ||
| 189 | line.map((token) => ({ | ||
| 190 | content: token.content, | ||
| 191 | color: token.color ?? null, | ||
| 192 | fontStyle: token.fontStyle ?? null, | ||
| 193 | })), | ||
| 194 | ); | ||
| 195 | } | ||
| 196 | |||
| 197 | async function createNativeReviewDiffHighlighter(): Promise<NativeReviewDiffHighlighterHandle> { | ||
| 198 | const nativeEngineModule = await import("react-native-shiki-engine"); | ||
| 199 | if (!nativeEngineModule.isNativeEngineAvailable()) { | ||
| 200 | throw new Error("Native Shiki engine is not available in this build."); | ||
| 201 | } | ||
| 202 | |||
| 203 | const highlighter = await createHighlighterCore({ | ||
| 204 | langs: NATIVE_REVIEW_DIFF_LANGUAGES, | ||
| 205 | themes: NATIVE_REVIEW_DIFF_SHIKI_THEMES, | ||
| 206 | engine: nativeEngineModule.createNativeEngine(), | ||
| 207 | }); | ||
| 208 | |||
| 209 | return { | ||
| 210 | engine: "native", | ||
| 211 | tokenize: (code, options) => normalizeTokens(highlighter.codeToTokensBase(code, options)), | ||
| 212 | }; | ||
| 213 | } | ||
| 214 | |||
| 215 | async function createJavascriptReviewDiffHighlighter(): Promise<NativeReviewDiffHighlighterHandle> { | ||
| 216 | const highlighter: HighlighterCore = await createHighlighterCore({ | ||
| 217 | langs: NATIVE_REVIEW_DIFF_LANGUAGES, | ||
| 218 | themes: NATIVE_REVIEW_DIFF_SHIKI_THEMES, | ||
| 219 | engine: createJavaScriptRegexEngine(), | ||
| 220 | }); | ||
| 221 | |||
| 222 | return { | ||
| 223 | engine: "javascript", | ||
| 224 | tokenize: (code, options) => normalizeTokens(highlighter.codeToTokensBase(code, options)), | ||
| 225 | }; | ||
| 226 | } | ||
| 227 | |||
| 228 | export async function getNativeReviewDiffHighlighter( | ||
| 229 | engine: NativeReviewDiffHighlightEngine = "native", | ||
| 230 | ): Promise<NativeReviewDiffHighlighterHandle> { | ||
| 231 | if (engine === "javascript") { | ||
| 232 | javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); | ||
| 233 | return javascriptHighlighterPromise; | ||
| 234 | } | ||
| 235 | |||
| 236 | nativeHighlighterPromise ??= createNativeReviewDiffHighlighter().catch((error: unknown) => { | ||
| 237 | console.warn("[debug-native-diff] native highlighter unavailable", { | ||
| 238 | error: error instanceof Error ? error.message : String(error), | ||
| 239 | }); | ||
| 240 | javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter(); | ||
| 241 | return javascriptHighlighterPromise; | ||
| 242 | }); | ||
| 243 | return nativeHighlighterPromise; | ||
| 244 | } | ||
| 245 | |||
| 246 | function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDiffLineRow { | ||
| 247 | return row.kind === "line" && typeof row.fileId === "string" && typeof row.content === "string"; | ||
| 248 | } | ||
| 249 | |||
| 250 | function groupLineRowsByFileId(rows: ReadonlyArray<NativeReviewDiffRow>) { | ||
| 251 | const rowsByFileId = new Map<string, NativeReviewDiffLineRow[]>(); | ||
| 252 | for (const row of rows) { | ||
| 253 | if (!isHighlightableLineRow(row)) { | ||
| 254 | continue; | ||
| 255 | } | ||
| 256 | |||
| 257 | const fileRows = rowsByFileId.get(row.fileId) ?? []; | ||
| 258 | fileRows.push(row); | ||
| 259 | rowsByFileId.set(row.fileId, fileRows); | ||
| 260 | } | ||
| 261 | return rowsByFileId; | ||
| 262 | } | ||
| 263 | |||
| 264 | function createFileMap(files: ReadonlyArray<NativeReviewDiffFile>) { | ||
| 265 | return new Map(files.map((file) => [file.id, file])); | ||
| 266 | } | ||
| 267 | |||
| 268 | function clampRowIndex(index: number, rows: ReadonlyArray<NativeReviewDiffRow>) { | ||
| 269 | if (rows.length === 0) { | ||
| 270 | return 0; | ||
| 271 | } | ||
| 272 | |||
| 273 | return Math.min(rows.length - 1, Math.max(0, Math.floor(index))); | ||
| 274 | } | ||
| 275 | |||
| 276 | function makePlainTokenFallback( | ||
| 277 | row: NativeReviewDiffLineRow, | ||
| 278 | ): ReadonlyArray<NativeReviewDiffToken> { | ||
| 279 | return [{ content: row.content || " ", color: null, fontStyle: null }]; | ||
| 280 | } | ||
| 281 | |||
| 282 | export async function highlightNativeReviewDiffVisibleRows( | ||
| 283 | input: HighlightNativeReviewDiffVisibleRowsInput, | ||
| 284 | ): Promise<{ | ||
| 285 | readonly engine: NativeReviewDiffHighlightEngine; | ||
| 286 | readonly tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>>; | ||
| 287 | readonly rowCount: number; | ||
| 288 | readonly durationMs: number; | ||
| 289 | }> { | ||
| 290 | const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native"); | ||
| 291 | if (input.signal?.aborted) { | ||
| 292 | return { engine: highlighter.engine, tokensByRowId: {}, rowCount: 0, durationMs: 0 }; | ||
| 293 | } | ||
| 294 | |||
| 295 | const startedAt = performance.now(); | ||
| 296 | const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme]; | ||
| 297 | const fileMap = createFileMap(input.files); | ||
| 298 | const overscanRows = input.overscanRows ?? NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS; | ||
| 299 | const maxRows = input.maxRows ?? NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS; | ||
| 300 | const startIndex = clampRowIndex(input.firstRowIndex - overscanRows, input.rows); | ||
| 301 | const endIndex = clampRowIndex(input.lastRowIndex + overscanRows, input.rows); | ||
| 302 | const selectedRows: NativeReviewDiffLineRow[] = []; | ||
| 303 | |||
| 304 | for ( | ||
| 305 | let rowIndex = startIndex; | ||
| 306 | rowIndex <= endIndex && selectedRows.length < maxRows; | ||
| 307 | rowIndex += 1 | ||
| 308 | ) { | ||
| 309 | const row = input.rows[rowIndex]; | ||
| 310 | if ( | ||
| 311 | row && | ||
| 312 | isHighlightableLineRow(row) && | ||
| 313 | !input.alreadyHighlightedRowIds?.has(row.id) && | ||
| 314 | fileMap.has(row.fileId) | ||
| 315 | ) { | ||
| 316 | selectedRows.push(row); | ||
| 317 | } | ||
| 318 | } | ||
| 319 | |||
| 320 | const tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>> = {}; | ||
| 321 | let segmentRows: NativeReviewDiffLineRow[] = []; | ||
| 322 | let segmentFile: NativeReviewDiffFile | undefined; | ||
| 323 | |||
| 324 | const flushSegment = () => { | ||
| 325 | if (!segmentFile || segmentRows.length === 0 || input.signal?.aborted) { | ||
| 326 | segmentRows = []; | ||
| 327 | segmentFile = undefined; | ||
| 328 | return; | ||
| 329 | } | ||
| 330 | |||
| 331 | const code = segmentRows.map((row) => row.content).join("\n"); | ||
| 332 | const tokenLines = highlighter.tokenize(code, { lang: segmentFile.language, theme }); | ||
| 333 | segmentRows.forEach((row, rowIndex) => { | ||
| 334 | tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); | ||
| 335 | }); | ||
| 336 | segmentRows = []; | ||
| 337 | segmentFile = undefined; | ||
| 338 | }; | ||
| 339 | |||
| 340 | for (const row of selectedRows) { | ||
| 341 | const file = fileMap.get(row.fileId); | ||
| 342 | if (!file) { | ||
| 343 | continue; | ||
| 344 | } | ||
| 345 | |||
| 346 | if (segmentFile && segmentFile.id !== file.id) { | ||
| 347 | flushSegment(); | ||
| 348 | } | ||
| 349 | |||
| 350 | segmentFile = file; | ||
| 351 | segmentRows.push(row); | ||
| 352 | } | ||
| 353 | flushSegment(); | ||
| 354 | |||
| 355 | return { | ||
| 356 | engine: highlighter.engine, | ||
| 357 | tokensByRowId, | ||
| 358 | rowCount: selectedRows.length, | ||
| 359 | durationMs: Math.round(performance.now() - startedAt), | ||
| 360 | }; | ||
| 361 | } | ||
| 362 | |||
| 363 | export async function streamNativeReviewDiffTokens( | ||
| 364 | input: StreamNativeReviewDiffTokenInput, | ||
| 365 | ): Promise<NativeReviewDiffHighlightEngine> { | ||
| 366 | const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native"); | ||
| 367 | const rowsByFileId = groupLineRowsByFileId(input.rows); | ||
| 368 | const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme]; | ||
| 369 | const chunkSize = input.chunkSize ?? NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE; | ||
| 370 | let chunkIndex = 0; | ||
| 371 | |||
| 372 | for (const file of input.files) { | ||
| 373 | const fileRows = rowsByFileId.get(file.id) ?? []; | ||
| 374 | for (let startIndex = 0; startIndex < fileRows.length; startIndex += chunkSize) { | ||
| 375 | if (input.signal?.aborted) { | ||
| 376 | return highlighter.engine; | ||
| 377 | } | ||
| 378 | |||
| 379 | const startedAt = performance.now(); | ||
| 380 | const chunkRows = fileRows.slice(startIndex, startIndex + chunkSize); | ||
| 381 | const code = chunkRows.map((row) => row.content).join("\n"); | ||
| 382 | const tokenLines = highlighter.tokenize(code, { lang: file.language, theme }); | ||
| 383 | const tokensByRowId: Record<string, ReadonlyArray<NativeReviewDiffToken>> = {}; | ||
| 384 | |||
| 385 | chunkRows.forEach((row, rowIndex) => { | ||
| 386 | tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); | ||
| 387 | }); | ||
| 388 | |||
| 389 | input.onChunk({ | ||
| 390 | chunkIndex, | ||
| 391 | fileId: file.id, | ||
| 392 | filePath: file.path, | ||
| 393 | language: file.language, | ||
| 394 | lineCount: chunkRows.length, | ||
| 395 | durationMs: Math.round(performance.now() - startedAt), | ||
| 396 | tokensByRowId, | ||
| 397 | }); | ||
| 398 | |||
| 399 | chunkIndex += 1; | ||
| 400 | await waitForNextFrame(); | ||
| 401 | } | ||
| 402 | } | ||
| 403 | |||
| 404 | return highlighter.engine; | ||
| 405 | } | ||
apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts created+67| ... | @@ -0,0 +1,67 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | const expoMocks = vi.hoisted(() => ({ | ||
| 4 | requireNativeView: vi.fn(), | ||
| 5 | })); | ||
| 6 | const nativeView = () => null; | ||
| 7 | const originalExpo = globalThis.expo; | ||
| 8 | |||
| 9 | function setExpoViewConfigAvailable() { | ||
| 10 | globalThis.expo = { | ||
| 11 | getViewConfig: vi.fn().mockReturnValue({ validAttributes: {}, directEventTypes: {} }), | ||
| 12 | } as unknown as typeof globalThis.expo; | ||
| 13 | } | ||
| 14 | |||
| 15 | vi.mock("expo", () => ({ | ||
| 16 | requireNativeView: expoMocks.requireNativeView, | ||
| 17 | })); | ||
| 18 | |||
| 19 | describe("resolveNativeReviewDiffView", () => { | ||
| 20 | beforeEach(() => { | ||
| 21 | vi.clearAllMocks(); | ||
| 22 | vi.resetModules(); | ||
| 23 | globalThis.expo = undefined as unknown as typeof globalThis.expo; | ||
| 24 | }); | ||
| 25 | |||
| 26 | afterEach(() => { | ||
| 27 | globalThis.expo = originalExpo; | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("returns null when the native review diff view config is unavailable", async () => { | ||
| 31 | const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); | ||
| 32 | expect(resolveNativeReviewDiffView()).toBeNull(); | ||
| 33 | expect(expoMocks.requireNativeView).not.toHaveBeenCalled(); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("returns the native review diff view when the view config is installed", async () => { | ||
| 37 | setExpoViewConfigAvailable(); | ||
| 38 | expoMocks.requireNativeView.mockReturnValue(nativeView); | ||
| 39 | const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); | ||
| 40 | expect(resolveNativeReviewDiffView()).toBe(nativeView); | ||
| 41 | expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3ReviewDiffSurface"); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("does not fall back to stale legacy native review diff view names", async () => { | ||
| 45 | globalThis.expo = { | ||
| 46 | getViewConfig: vi.fn().mockImplementation((moduleName: string) => { | ||
| 47 | if (moduleName === "T3ReviewDiffView") { | ||
| 48 | return { validAttributes: {}, directEventTypes: {} }; | ||
| 49 | } | ||
| 50 | return null; | ||
| 51 | }), | ||
| 52 | } as unknown as typeof globalThis.expo; | ||
| 53 | expoMocks.requireNativeView.mockReturnValue(nativeView); | ||
| 54 | const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); | ||
| 55 | expect(resolveNativeReviewDiffView()).toBeNull(); | ||
| 56 | expect(expoMocks.requireNativeView).not.toHaveBeenCalled(); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("returns null when the view manager cannot be required", async () => { | ||
| 60 | setExpoViewConfigAvailable(); | ||
| 61 | expoMocks.requireNativeView.mockImplementation(() => { | ||
| 62 | throw new Error("boom"); | ||
| 63 | }); | ||
| 64 | const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); | ||
| 65 | expect(resolveNativeReviewDiffView()).toBeNull(); | ||
| 66 | }); | ||
| 67 | }); | ||
apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts created+155| ... | @@ -0,0 +1,155 @@ | ||
| 1 | import type { ComponentType } from "react"; | ||
| 2 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; | ||
| 3 | import { requireNativeView } from "expo"; | ||
| 4 | |||
| 5 | const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface"; | ||
| 6 | |||
| 7 | interface ExpoGlobalWithViewConfig { | ||
| 8 | readonly expo?: { | ||
| 9 | getViewConfig?: (moduleName: string, viewName?: string) => unknown; | ||
| 10 | }; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface NativeReviewDiffRow { | ||
| 14 | readonly kind: "file" | "hunk" | "line" | "notice" | "comment"; | ||
| 15 | readonly id: string; | ||
| 16 | readonly fileId?: string; | ||
| 17 | readonly filePath?: string; | ||
| 18 | readonly previousPath?: string | null; | ||
| 19 | readonly changeType?: | ||
| 20 | | "modified" | ||
| 21 | | "new" | ||
| 22 | | "deleted" | ||
| 23 | | "renamed" | ||
| 24 | | "rename-pure" | ||
| 25 | | "rename-changed"; | ||
| 26 | readonly additions?: number; | ||
| 27 | readonly deletions?: number; | ||
| 28 | readonly text?: string; | ||
| 29 | readonly content?: string; | ||
| 30 | readonly change?: "context" | "add" | "delete"; | ||
| 31 | readonly oldLineNumber?: number | null; | ||
| 32 | readonly newLineNumber?: number | null; | ||
| 33 | readonly wordDiffRanges?: ReadonlyArray<NativeReviewDiffWordDiffRange>; | ||
| 34 | readonly commentText?: string; | ||
| 35 | readonly commentRangeLabel?: string; | ||
| 36 | readonly commentSectionTitle?: string; | ||
| 37 | } | ||
| 38 | |||
| 39 | export interface NativeReviewDiffWordDiffRange { | ||
| 40 | readonly start: number; | ||
| 41 | readonly end: number; | ||
| 42 | } | ||
| 43 | |||
| 44 | export interface NativeReviewDiffToken { | ||
| 45 | readonly content: string; | ||
| 46 | readonly color: string | null; | ||
| 47 | readonly fontStyle: number | null; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface NativeReviewDiffTheme { | ||
| 51 | readonly background: string; | ||
| 52 | readonly text: string; | ||
| 53 | readonly mutedText: string; | ||
| 54 | readonly headerBackground: string; | ||
| 55 | readonly border: string; | ||
| 56 | readonly hunkBackground: string; | ||
| 57 | readonly hunkText: string; | ||
| 58 | readonly addBackground: string; | ||
| 59 | readonly deleteBackground: string; | ||
| 60 | readonly addBar: string; | ||
| 61 | readonly deleteBar: string; | ||
| 62 | readonly addText: string; | ||
| 63 | readonly deleteText: string; | ||
| 64 | } | ||
| 65 | |||
| 66 | export interface NativeReviewDiffStyle { | ||
| 67 | readonly rowHeight?: number; | ||
| 68 | readonly contentWidth?: number; | ||
| 69 | readonly changeBarWidth?: number; | ||
| 70 | readonly gutterWidth?: number; | ||
| 71 | readonly codePadding?: number; | ||
| 72 | readonly textVerticalInset?: number; | ||
| 73 | readonly fileHeaderHeight?: number; | ||
| 74 | readonly fileHeaderHorizontalMargin?: number; | ||
| 75 | readonly fileHeaderVerticalMargin?: number; | ||
| 76 | readonly fileHeaderCornerRadius?: number; | ||
| 77 | readonly fileHeaderHorizontalPadding?: number; | ||
| 78 | readonly fileHeaderPathRightPadding?: number; | ||
| 79 | readonly fileHeaderCountColumnWidth?: number; | ||
| 80 | readonly fileHeaderCountGap?: number; | ||
| 81 | readonly codeFontSize?: number; | ||
| 82 | readonly codeFontWeight?: string; | ||
| 83 | readonly lineNumberFontSize?: number; | ||
| 84 | readonly lineNumberFontWeight?: string; | ||
| 85 | readonly hunkFontSize?: number; | ||
| 86 | readonly hunkFontWeight?: string; | ||
| 87 | readonly fileHeaderFontSize?: number; | ||
| 88 | readonly fileHeaderFontWeight?: string; | ||
| 89 | readonly fileHeaderMetaFontSize?: number; | ||
| 90 | readonly fileHeaderMetaFontWeight?: string; | ||
| 91 | readonly fileHeaderSubtextFontSize?: number; | ||
| 92 | readonly fileHeaderSubtextFontWeight?: string; | ||
| 93 | readonly fileHeaderStatusFontSize?: number; | ||
| 94 | readonly fileHeaderStatusFontWeight?: string; | ||
| 95 | readonly emptyStateFontSize?: number; | ||
| 96 | readonly emptyStateFontWeight?: string; | ||
| 97 | } | ||
| 98 | |||
| 99 | export interface NativeReviewDiffViewProps extends ViewProps { | ||
| 100 | readonly rowsJson: string; | ||
| 101 | readonly tokensJson?: string; | ||
| 102 | readonly tokensPatchJson?: string; | ||
| 103 | readonly tokensResetKey?: string; | ||
| 104 | readonly collapsedFileIdsJson?: string; | ||
| 105 | readonly viewedFileIdsJson?: string; | ||
| 106 | readonly selectedRowIdsJson?: string; | ||
| 107 | readonly collapsedCommentIdsJson?: string; | ||
| 108 | readonly appearanceScheme: "light" | "dark"; | ||
| 109 | readonly themeJson: string; | ||
| 110 | readonly styleJson?: string; | ||
| 111 | readonly rowHeight: number; | ||
| 112 | readonly contentWidth: number; | ||
| 113 | readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void; | ||
| 114 | readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; | ||
| 115 | readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; | ||
| 116 | readonly onPressLine?: ( | ||
| 117 | event: NativeSyntheticEvent<{ | ||
| 118 | readonly rowId?: string; | ||
| 119 | readonly fileId?: string; | ||
| 120 | readonly gesture?: "tap" | "longPress"; | ||
| 121 | readonly oldLineNumber?: number; | ||
| 122 | readonly newLineNumber?: number; | ||
| 123 | readonly change?: "context" | "add" | "delete"; | ||
| 124 | }>, | ||
| 125 | ) => void; | ||
| 126 | readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void; | ||
| 127 | } | ||
| 128 | |||
| 129 | let cachedNativeReviewDiffView: ComponentType<NativeReviewDiffViewProps> | undefined; | ||
| 130 | |||
| 131 | function getExpoViewConfig(moduleName: string) { | ||
| 132 | return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( | ||
| 133 | moduleName, | ||
| 134 | ); | ||
| 135 | } | ||
| 136 | |||
| 137 | export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null { | ||
| 138 | if (cachedNativeReviewDiffView) { | ||
| 139 | return cachedNativeReviewDiffView; | ||
| 140 | } | ||
| 141 | |||
| 142 | if (getExpoViewConfig(NATIVE_REVIEW_DIFF_MODULE_NAME) == null) { | ||
| 143 | return null; | ||
| 144 | } | ||
| 145 | |||
| 146 | try { | ||
| 147 | cachedNativeReviewDiffView = requireNativeView<NativeReviewDiffViewProps>( | ||
| 148 | NATIVE_REVIEW_DIFF_MODULE_NAME, | ||
| 149 | ); | ||
| 150 | } catch { | ||
| 151 | return null; | ||
| 152 | } | ||
| 153 | |||
| 154 | return cachedNativeReviewDiffView ?? null; | ||
| 155 | } | ||
apps/mobile/src/features/diffs/nativeReviewDiffTypes.ts created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | export type NativeReviewDiffLanguage = | ||
| 2 | | "bash" | ||
| 3 | | "diff" | ||
| 4 | | "javascript" | ||
| 5 | | "json" | ||
| 6 | | "jsx" | ||
| 7 | | "tsx" | ||
| 8 | | "typescript" | ||
| 9 | | "yaml"; | ||
| 10 | |||
| 11 | export interface NativeReviewDiffFile { | ||
| 12 | readonly id: string; | ||
| 13 | readonly path: string; | ||
| 14 | readonly language: NativeReviewDiffLanguage; | ||
| 15 | readonly additions: number; | ||
| 16 | readonly deletions: number; | ||
| 17 | } | ||
apps/mobile/src/features/home/HomeScreen.tsx created+426| ... | @@ -0,0 +1,426 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentScopedProjectShell, | ||
| 3 | EnvironmentScopedThreadShell, | ||
| 4 | VcsStatusState, | ||
| 5 | } from "@t3tools/client-runtime"; | ||
| 6 | import { SymbolView } from "expo-symbols"; | ||
| 7 | import { useCallback, useMemo, useState } from "react"; | ||
| 8 | import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; | ||
| 9 | import * as Arr from "effect/Array"; | ||
| 10 | import * as Order from "effect/Order"; | ||
| 11 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 12 | |||
| 13 | import { AppText as Text } from "../../components/AppText"; | ||
| 14 | import { EmptyState } from "../../components/EmptyState"; | ||
| 15 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | ||
| 16 | import type { SavedRemoteConnection } from "../../lib/connection"; | ||
| 17 | import { scopedProjectKey } from "../../lib/scopedEntities"; | ||
| 18 | import { relativeTime } from "../../lib/time"; | ||
| 19 | import type { RemoteCatalogState } from "../../state/use-remote-catalog"; | ||
| 20 | import { useVcsStatus } from "../../state/use-vcs-status"; | ||
| 21 | import { threadStatusTone } from "../threads/threadPresentation"; | ||
| 22 | |||
| 23 | /* ─── Types ──────────────────────────────────────────────────────────── */ | ||
| 24 | |||
| 25 | interface HomeScreenProps { | ||
| 26 | readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>; | ||
| 27 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | ||
| 28 | readonly catalogState: RemoteCatalogState; | ||
| 29 | readonly savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>; | ||
| 30 | readonly searchQuery: string; | ||
| 31 | readonly onAddConnection: () => void; | ||
| 32 | readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; | ||
| 33 | } | ||
| 34 | |||
| 35 | interface ProjectGroup { | ||
| 36 | readonly key: string; | ||
| 37 | readonly project: EnvironmentScopedProjectShell; | ||
| 38 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | ||
| 39 | } | ||
| 40 | |||
| 41 | const projectGroupActivityOrder = Order.mapInput( | ||
| 42 | Order.Struct({ | ||
| 43 | activityAt: Order.flip(Order.Number), | ||
| 44 | }), | ||
| 45 | (group: ProjectGroup) => ({ | ||
| 46 | activityAt: new Date(group.threads[0]!.updatedAt ?? group.threads[0]!.createdAt).getTime(), | ||
| 47 | }), | ||
| 48 | ); | ||
| 49 | |||
| 50 | /* ─── Status indicator colors ────────────────────────────────────────── */ | ||
| 51 | |||
| 52 | function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: string } { | ||
| 53 | switch (thread.session?.status) { | ||
| 54 | case "running": | ||
| 55 | return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; | ||
| 56 | case "ready": | ||
| 57 | return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" }; | ||
| 58 | case "starting": | ||
| 59 | return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" }; | ||
| 60 | case "error": | ||
| 61 | return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" }; | ||
| 62 | default: | ||
| 63 | return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" }; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | const COLLAPSED_THREAD_LIMIT = 6; | ||
| 68 | |||
| 69 | function deriveEmptyState(props: { | ||
| 70 | readonly catalogState: RemoteCatalogState; | ||
| 71 | readonly projectCount: number; | ||
| 72 | }): { readonly title: string; readonly detail: string; readonly loading: boolean } { | ||
| 73 | const { catalogState } = props; | ||
| 74 | if (catalogState.isLoadingSavedConnections) { | ||
| 75 | return { | ||
| 76 | title: "Loading environments", | ||
| 77 | detail: "Checking saved environments on this device.", | ||
| 78 | loading: true, | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | if (!catalogState.hasSavedConnections) { | ||
| 83 | return { | ||
| 84 | title: "No environments connected", | ||
| 85 | detail: "Add an environment to load projects and start coding sessions.", | ||
| 86 | loading: false, | ||
| 87 | }; | ||
| 88 | } | ||
| 89 | |||
| 90 | if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) { | ||
| 91 | return { | ||
| 92 | title: "Environment unavailable", | ||
| 93 | detail: | ||
| 94 | catalogState.connectionError ?? | ||
| 95 | "The saved environment is offline. Check the URL or start the environment, then retry.", | ||
| 96 | loading: false, | ||
| 97 | }; | ||
| 98 | } | ||
| 99 | |||
| 100 | if ( | ||
| 101 | catalogState.hasConnectingEnvironment && | ||
| 102 | !catalogState.hasLoadedShellSnapshot && | ||
| 103 | catalogState.connectionError === null | ||
| 104 | ) { | ||
| 105 | return { | ||
| 106 | title: "Connecting to environment", | ||
| 107 | detail: "Loading projects and threads from the saved environment.", | ||
| 108 | loading: true, | ||
| 109 | }; | ||
| 110 | } | ||
| 111 | |||
| 112 | if (props.projectCount === 0 && catalogState.hasLoadedShellSnapshot) { | ||
| 113 | return { | ||
| 114 | title: "No projects found", | ||
| 115 | detail: "The connected environment did not report any projects.", | ||
| 116 | loading: false, | ||
| 117 | }; | ||
| 118 | } | ||
| 119 | |||
| 120 | return { | ||
| 121 | title: "No threads yet", | ||
| 122 | detail: "Create a task to start a new coding session in one of your connected projects.", | ||
| 123 | loading: false, | ||
| 124 | }; | ||
| 125 | } | ||
| 126 | |||
| 127 | /* ─── Project group header ───────────────────────────────────────────── */ | ||
| 128 | |||
| 129 | function ProjectGroupLabel(props: { | ||
| 130 | readonly project: EnvironmentScopedProjectShell; | ||
| 131 | readonly totalThreadCount: number; | ||
| 132 | readonly httpBaseUrl: string | null; | ||
| 133 | readonly bearerToken: string | null; | ||
| 134 | readonly isExpanded: boolean; | ||
| 135 | readonly onToggleExpand: () => void; | ||
| 136 | }) { | ||
| 137 | const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT; | ||
| 138 | |||
| 139 | return ( | ||
| 140 | <View className="flex-row items-center gap-2.5 px-1 pb-2"> | ||
| 141 | <ProjectFavicon | ||
| 142 | size={18} | ||
| 143 | projectTitle={props.project.title} | ||
| 144 | httpBaseUrl={props.httpBaseUrl} | ||
| 145 | workspaceRoot={props.project.workspaceRoot} | ||
| 146 | bearerToken={props.bearerToken} | ||
| 147 | /> | ||
| 148 | <Text | ||
| 149 | className="flex-1 text-[13px] font-t3-bold uppercase text-foreground-muted" | ||
| 150 | style={{ letterSpacing: 0.6 }} | ||
| 151 | numberOfLines={1} | ||
| 152 | > | ||
| 153 | {props.project.title} | ||
| 154 | </Text> | ||
| 155 | |||
| 156 | {hiddenCount > 0 ? ( | ||
| 157 | <Pressable onPress={props.onToggleExpand} hitSlop={8}> | ||
| 158 | <Text | ||
| 159 | className="text-[13px] font-t3-bold text-foreground-muted" | ||
| 160 | style={{ letterSpacing: 0.6 }} | ||
| 161 | > | ||
| 162 | {props.isExpanded ? "Show less" : `${hiddenCount} more`} | ||
| 163 | </Text> | ||
| 164 | </Pressable> | ||
| 165 | ) : null} | ||
| 166 | </View> | ||
| 167 | ); | ||
| 168 | } | ||
| 169 | |||
| 170 | /* ─── Git summary line ──────────────────────────────────────────────── */ | ||
| 171 | |||
| 172 | function gitSummaryParts(gitStatus: VcsStatusState): ReadonlyArray<string> { | ||
| 173 | if (!gitStatus.data) return []; | ||
| 174 | const { data } = gitStatus; | ||
| 175 | const parts: string[] = []; | ||
| 176 | if (data.hasWorkingTreeChanges) { | ||
| 177 | parts.push(`${data.workingTree.files.length} changed`); | ||
| 178 | } | ||
| 179 | if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`); | ||
| 180 | if (data.behindCount > 0) parts.push(`${data.behindCount} behind`); | ||
| 181 | if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`); | ||
| 182 | return parts; | ||
| 183 | } | ||
| 184 | |||
| 185 | /* ─── Thread row ─────────────────────────────────────────────────────── */ | ||
| 186 | |||
| 187 | function ThreadRow(props: { | ||
| 188 | readonly thread: EnvironmentScopedThreadShell; | ||
| 189 | readonly projectCwd: string | null; | ||
| 190 | readonly onPress: () => void; | ||
| 191 | readonly isLast: boolean; | ||
| 192 | }) { | ||
| 193 | const separatorColor = useThemeColor("--color-separator"); | ||
| 194 | const { bg, fg } = statusColors(props.thread); | ||
| 195 | const tone = threadStatusTone(props.thread); | ||
| 196 | const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); | ||
| 197 | const branch = props.thread.branch; | ||
| 198 | |||
| 199 | // Subscribe to live git status — only when thread has a branch set. | ||
| 200 | // Threads sharing the same cwd share one WS subscription via ref-counting. | ||
| 201 | const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null; | ||
| 202 | const gitStatus = useVcsStatus({ | ||
| 203 | environmentId: cwd ? props.thread.environmentId : null, | ||
| 204 | cwd, | ||
| 205 | }); | ||
| 206 | const gitParts = gitSummaryParts(gitStatus); | ||
| 207 | |||
| 208 | return ( | ||
| 209 | <Pressable onPress={props.onPress} style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}> | ||
| 210 | <View | ||
| 211 | style={{ | ||
| 212 | flexDirection: "row", | ||
| 213 | paddingLeft: 16, | ||
| 214 | paddingRight: 16, | ||
| 215 | paddingVertical: 10, | ||
| 216 | gap: 12, | ||
| 217 | borderBottomWidth: props.isLast ? 0 : 1, | ||
| 218 | borderBottomColor: separatorColor, | ||
| 219 | }} | ||
| 220 | > | ||
| 221 | {/* Git status indicator */} | ||
| 222 | <View | ||
| 223 | style={{ | ||
| 224 | width: 30, | ||
| 225 | height: 30, | ||
| 226 | borderRadius: 9, | ||
| 227 | backgroundColor: bg, | ||
| 228 | alignItems: "center", | ||
| 229 | justifyContent: "center", | ||
| 230 | marginTop: 2, | ||
| 231 | }} | ||
| 232 | > | ||
| 233 | <SymbolView name="arrow.triangle.branch" size={13} tintColor={fg} type="monochrome" /> | ||
| 234 | </View> | ||
| 235 | |||
| 236 | {/* Content */} | ||
| 237 | <View style={{ flex: 1, gap: 3 }}> | ||
| 238 | {/* Title + Status + Timestamp */} | ||
| 239 | <View className="flex-row items-center justify-between gap-2"> | ||
| 240 | <Text | ||
| 241 | className="flex-1 text-[15px] font-t3-bold leading-[20px] text-foreground" | ||
| 242 | numberOfLines={1} | ||
| 243 | > | ||
| 244 | {props.thread.title} | ||
| 245 | </Text> | ||
| 246 | <View className="flex-row items-center gap-2"> | ||
| 247 | <View | ||
| 248 | className={tone.pillClassName} | ||
| 249 | style={{ borderRadius: 99, paddingHorizontal: 6, paddingVertical: 2 }} | ||
| 250 | > | ||
| 251 | <Text className={`text-[10px] font-t3-bold ${tone.textClassName}`}> | ||
| 252 | {tone.label} | ||
| 253 | </Text> | ||
| 254 | </View> | ||
| 255 | <Text | ||
| 256 | className="text-[12px] text-foreground-tertiary" | ||
| 257 | style={{ fontVariant: ["tabular-nums"] }} | ||
| 258 | > | ||
| 259 | {timestamp} | ||
| 260 | </Text> | ||
| 261 | </View> | ||
| 262 | </View> | ||
| 263 | |||
| 264 | {/* Branch + git info */} | ||
| 265 | {branch ? ( | ||
| 266 | <View className="flex-row items-center gap-1.5" style={{ marginTop: 1 }}> | ||
| 267 | <SymbolView | ||
| 268 | name="arrow.triangle.branch" | ||
| 269 | size={10} | ||
| 270 | tintColor="#737373" | ||
| 271 | type="monochrome" | ||
| 272 | /> | ||
| 273 | <Text | ||
| 274 | className="text-[11px] text-foreground-tertiary" | ||
| 275 | numberOfLines={1} | ||
| 276 | style={{ fontFamily: "monospace" }} | ||
| 277 | > | ||
| 278 | {branch} | ||
| 279 | </Text> | ||
| 280 | {gitParts.length > 0 ? ( | ||
| 281 | <Text className="text-[11px] text-foreground-tertiary"> | ||
| 282 | {" · " + gitParts.join(" · ")} | ||
| 283 | </Text> | ||
| 284 | ) : null} | ||
| 285 | </View> | ||
| 286 | ) : null} | ||
| 287 | </View> | ||
| 288 | </View> | ||
| 289 | </Pressable> | ||
| 290 | ); | ||
| 291 | } | ||
| 292 | |||
| 293 | /* ─── Main screen ────────────────────────────────────────────────────── */ | ||
| 294 | |||
| 295 | export function HomeScreen(props: HomeScreenProps) { | ||
| 296 | const [expandedProjects, setExpandedProjects] = useState<Set<string>>(() => new Set()); | ||
| 297 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 298 | |||
| 299 | const toggleExpanded = useCallback((key: string) => { | ||
| 300 | setExpandedProjects((prev) => { | ||
| 301 | const next = new Set(prev); | ||
| 302 | if (next.has(key)) next.delete(key); | ||
| 303 | else next.add(key); | ||
| 304 | return next; | ||
| 305 | }); | ||
| 306 | }, []); | ||
| 307 | |||
| 308 | /* Build project title lookup for search */ | ||
| 309 | const projectTitleByKey = useMemo(() => { | ||
| 310 | const map = new Map<string, string>(); | ||
| 311 | for (const p of props.projects) { | ||
| 312 | map.set(scopedProjectKey(p.environmentId, p.id), p.title); | ||
| 313 | } | ||
| 314 | return map; | ||
| 315 | }, [props.projects]); | ||
| 316 | |||
| 317 | /* Filter threads by search query */ | ||
| 318 | const filteredThreads = useMemo(() => { | ||
| 319 | const q = props.searchQuery.trim().toLowerCase(); | ||
| 320 | if (!q) return props.threads; | ||
| 321 | return props.threads.filter((t) => { | ||
| 322 | if (t.title.toLowerCase().includes(q)) return true; | ||
| 323 | const key = scopedProjectKey(t.environmentId, t.projectId); | ||
| 324 | return projectTitleByKey.get(key)?.toLowerCase().includes(q) ?? false; | ||
| 325 | }); | ||
| 326 | }, [props.threads, props.searchQuery, projectTitleByKey]); | ||
| 327 | |||
| 328 | /* Group filtered threads by project */ | ||
| 329 | const projectGroups = useMemo<ReadonlyArray<ProjectGroup>>(() => { | ||
| 330 | const byProject = new Map<string, EnvironmentScopedThreadShell[]>(); | ||
| 331 | for (const thread of filteredThreads) { | ||
| 332 | const key = scopedProjectKey(thread.environmentId, thread.projectId); | ||
| 333 | const existing = byProject.get(key); | ||
| 334 | if (existing) existing.push(thread); | ||
| 335 | else byProject.set(key, [thread]); | ||
| 336 | } | ||
| 337 | |||
| 338 | const groups: ProjectGroup[] = []; | ||
| 339 | for (const project of props.projects) { | ||
| 340 | const key = scopedProjectKey(project.environmentId, project.id); | ||
| 341 | const threads = byProject.get(key); | ||
| 342 | if (threads && threads.length > 0) { | ||
| 343 | groups.push({ key, project, threads }); | ||
| 344 | } | ||
| 345 | } | ||
| 346 | |||
| 347 | return Arr.sort(groups, projectGroupActivityOrder); | ||
| 348 | }, [props.projects, filteredThreads]); | ||
| 349 | |||
| 350 | /* Empty states */ | ||
| 351 | const hasAnyThreads = props.threads.length > 0; | ||
| 352 | const hasResults = filteredThreads.length > 0; | ||
| 353 | const emptyState = deriveEmptyState({ | ||
| 354 | catalogState: props.catalogState, | ||
| 355 | projectCount: props.projects.length, | ||
| 356 | }); | ||
| 357 | |||
| 358 | return ( | ||
| 359 | <ScrollView | ||
| 360 | contentInsetAdjustmentBehavior="automatic" | ||
| 361 | showsVerticalScrollIndicator={false} | ||
| 362 | keyboardDismissMode="on-drag" | ||
| 363 | keyboardShouldPersistTaps="handled" | ||
| 364 | className="flex-1 bg-screen" | ||
| 365 | contentContainerStyle={{ | ||
| 366 | paddingHorizontal: 16, | ||
| 367 | paddingTop: 8, | ||
| 368 | paddingBottom: 24, | ||
| 369 | gap: 20, | ||
| 370 | }} | ||
| 371 | > | ||
| 372 | {!hasAnyThreads ? ( | ||
| 373 | <View> | ||
| 374 | <EmptyState | ||
| 375 | title={emptyState.title} | ||
| 376 | detail={emptyState.detail} | ||
| 377 | actionLabel={!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined} | ||
| 378 | onAction={!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined} | ||
| 379 | /> | ||
| 380 | {emptyState.loading ? ( | ||
| 381 | <View className="absolute right-5 top-5"> | ||
| 382 | <ActivityIndicator color={accentColor} /> | ||
| 383 | </View> | ||
| 384 | ) : null} | ||
| 385 | </View> | ||
| 386 | ) : !hasResults ? ( | ||
| 387 | <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} /> | ||
| 388 | ) : ( | ||
| 389 | projectGroups.map((group) => { | ||
| 390 | const connection = props.savedConnectionsById[group.project.environmentId]; | ||
| 391 | const isExpanded = expandedProjects.has(group.key); | ||
| 392 | const visibleThreads = isExpanded | ||
| 393 | ? group.threads | ||
| 394 | : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); | ||
| 395 | |||
| 396 | return ( | ||
| 397 | <View key={group.key}> | ||
| 398 | <ProjectGroupLabel | ||
| 399 | project={group.project} | ||
| 400 | totalThreadCount={group.threads.length} | ||
| 401 | httpBaseUrl={connection?.httpBaseUrl ?? null} | ||
| 402 | bearerToken={connection?.bearerToken ?? null} | ||
| 403 | isExpanded={isExpanded} | ||
| 404 | onToggleExpand={() => toggleExpanded(group.key)} | ||
| 405 | /> | ||
| 406 | <View | ||
| 407 | className="overflow-hidden rounded-[20px] bg-card" | ||
| 408 | style={{ borderCurve: "continuous" }} | ||
| 409 | > | ||
| 410 | {visibleThreads.map((thread, i) => ( | ||
| 411 | <ThreadRow | ||
| 412 | key={`${thread.environmentId}:${thread.id}`} | ||
| 413 | thread={thread} | ||
| 414 | projectCwd={group.project.workspaceRoot} | ||
| 415 | onPress={() => props.onSelectThread(thread)} | ||
| 416 | isLast={i === visibleThreads.length - 1} | ||
| 417 | /> | ||
| 418 | ))} | ||
| 419 | </View> | ||
| 420 | </View> | ||
| 421 | ); | ||
| 422 | }) | ||
| 423 | )} | ||
| 424 | </ScrollView> | ||
| 425 | ); | ||
| 426 | } | ||
apps/mobile/src/features/projects/AddProjectScreen.tsx created+827| ... | @@ -0,0 +1,827 @@ | ||
| 1 | import { | ||
| 2 | addProjectRemoteSourceLabel, | ||
| 3 | addProjectRemoteSourcePathHint, | ||
| 4 | addProjectRemoteSourceProvider, | ||
| 5 | appendBrowsePathSegment, | ||
| 6 | buildAddProjectRemoteSourceReadiness, | ||
| 7 | buildProjectCreateCommand, | ||
| 8 | canNavigateUp, | ||
| 9 | ensureBrowseDirectoryPath, | ||
| 10 | findExistingAddProject, | ||
| 11 | getAddProjectInitialQuery, | ||
| 12 | getBrowseDirectoryPath, | ||
| 13 | getBrowseLeafPathSegment, | ||
| 14 | getBrowseParentPath, | ||
| 15 | hasTrailingPathSeparator, | ||
| 16 | inferProjectTitleFromPath, | ||
| 17 | isFilesystemBrowseQuery, | ||
| 18 | resolveAddProjectPath, | ||
| 19 | sortAddProjectProviderSources, | ||
| 20 | type AddProjectRemoteSource, | ||
| 21 | } from "@t3tools/client-runtime"; | ||
| 22 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; | ||
| 23 | import { useLocalSearchParams, useRouter } from "expo-router"; | ||
| 24 | import { SymbolView } from "expo-symbols"; | ||
| 25 | import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; | ||
| 26 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; | ||
| 27 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 28 | import * as Arr from "effect/Array"; | ||
| 29 | import * as Order from "effect/Order"; | ||
| 30 | |||
| 31 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 32 | import { ErrorBanner } from "../../components/ErrorBanner"; | ||
| 33 | import { SourceControlIcon } from "../../components/SourceControlIcon"; | ||
| 34 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 35 | import { uuidv4 } from "../../lib/uuid"; | ||
| 36 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | ||
| 37 | import { useFilesystemBrowse } from "../../state/use-filesystem-browse"; | ||
| 38 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | ||
| 39 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | ||
| 40 | import { | ||
| 41 | refreshSourceControlDiscoveryForEnvironment, | ||
| 42 | useSourceControlDiscovery, | ||
| 43 | } from "../../state/use-source-control-discovery"; | ||
| 44 | import { NewTaskSheetHeader } from "../threads/NewTaskSheetHeader"; | ||
| 45 | |||
| 46 | interface EnvironmentOption { | ||
| 47 | readonly environmentId: EnvironmentId; | ||
| 48 | readonly label: string; | ||
| 49 | readonly platform: string; | ||
| 50 | readonly baseDirectory: string | null; | ||
| 51 | } | ||
| 52 | |||
| 53 | const environmentOptionOrder = Order.mapInput( | ||
| 54 | Order.Struct({ | ||
| 55 | label: Order.String, | ||
| 56 | }), | ||
| 57 | (environment: EnvironmentOption) => ({ label: environment.label }), | ||
| 58 | ); | ||
| 59 | |||
| 60 | const browseEntryOrder = Order.mapInput( | ||
| 61 | Order.String, | ||
| 62 | (entry: { readonly name: string }) => entry.name, | ||
| 63 | ); | ||
| 64 | |||
| 65 | function platformFromOs(os: string | null | undefined): string { | ||
| 66 | if (os === "windows") return "Win32"; | ||
| 67 | if (os === "darwin") return "MacIntel"; | ||
| 68 | if (os === "linux") return "Linux"; | ||
| 69 | return ""; | ||
| 70 | } | ||
| 71 | |||
| 72 | function errorMessage(error: unknown): string { | ||
| 73 | return error instanceof Error && error.message.trim().length > 0 | ||
| 74 | ? error.message | ||
| 75 | : "An error occurred."; | ||
| 76 | } | ||
| 77 | |||
| 78 | function stringParam(value: string | string[] | undefined): string | null { | ||
| 79 | if (Array.isArray(value)) return value[0] ?? null; | ||
| 80 | return value ?? null; | ||
| 81 | } | ||
| 82 | |||
| 83 | function sourceFromParam(value: string | string[] | undefined): AddProjectRemoteSource { | ||
| 84 | const source = stringParam(value); | ||
| 85 | if ( | ||
| 86 | source === "url" || | ||
| 87 | source === "github" || | ||
| 88 | source === "gitlab" || | ||
| 89 | source === "bitbucket" || | ||
| 90 | source === "azure-devops" | ||
| 91 | ) { | ||
| 92 | return source; | ||
| 93 | } | ||
| 94 | return "url"; | ||
| 95 | } | ||
| 96 | |||
| 97 | function SectionTitle(props: { readonly children: string }) { | ||
| 98 | return ( | ||
| 99 | <Text | ||
| 100 | className="px-1 text-[11px] font-t3-bold uppercase text-foreground-muted" | ||
| 101 | style={{ letterSpacing: 0.7 }} | ||
| 102 | > | ||
| 103 | {props.children} | ||
| 104 | </Text> | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | |||
| 108 | function AddProjectShell(props: { | ||
| 109 | readonly title: string; | ||
| 110 | readonly closeToNew?: boolean; | ||
| 111 | readonly children: ReactNode; | ||
| 112 | }) { | ||
| 113 | const router = useRouter(); | ||
| 114 | const insets = useSafeAreaInsets(); | ||
| 115 | |||
| 116 | return ( | ||
| 117 | <View className="flex-1 bg-sheet"> | ||
| 118 | <NewTaskSheetHeader | ||
| 119 | eyebrow="New project" | ||
| 120 | title={props.title} | ||
| 121 | control={{ | ||
| 122 | icon: props.closeToNew ? "xmark" : "chevron.left", | ||
| 123 | onPress: () => router.back(), | ||
| 124 | }} | ||
| 125 | /> | ||
| 126 | |||
| 127 | <ScrollView | ||
| 128 | keyboardShouldPersistTaps="handled" | ||
| 129 | contentInsetAdjustmentBehavior="automatic" | ||
| 130 | contentContainerStyle={{ | ||
| 131 | paddingHorizontal: 20, | ||
| 132 | paddingTop: 16, | ||
| 133 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 134 | gap: 10, | ||
| 135 | }} | ||
| 136 | > | ||
| 137 | {props.children} | ||
| 138 | </ScrollView> | ||
| 139 | </View> | ||
| 140 | ); | ||
| 141 | } | ||
| 142 | |||
| 143 | function ListSection(props: { readonly children: ReactNode }) { | ||
| 144 | return <View className="overflow-hidden rounded-[24px] bg-card">{props.children}</View>; | ||
| 145 | } | ||
| 146 | |||
| 147 | function ListRow(props: { | ||
| 148 | readonly title: string; | ||
| 149 | readonly subtitle?: string | null; | ||
| 150 | readonly icon: ReactNode; | ||
| 151 | readonly disabled?: boolean; | ||
| 152 | readonly selected?: boolean; | ||
| 153 | readonly isFirst?: boolean; | ||
| 154 | readonly right?: ReactNode; | ||
| 155 | readonly onPress?: () => void; | ||
| 156 | }) { | ||
| 157 | const borderColor = useThemeColor("--color-border-subtle"); | ||
| 158 | const chevronColor = useThemeColor("--color-chevron"); | ||
| 159 | |||
| 160 | return ( | ||
| 161 | <Pressable | ||
| 162 | disabled={props.disabled} | ||
| 163 | onPress={props.onPress} | ||
| 164 | className="bg-card px-3.5 py-2.5 active:opacity-70" | ||
| 165 | style={{ | ||
| 166 | opacity: props.disabled ? 0.45 : 1, | ||
| 167 | borderTopWidth: props.isFirst ? 0 : 1, | ||
| 168 | borderTopColor: borderColor, | ||
| 169 | }} | ||
| 170 | > | ||
| 171 | <View className="flex-row items-center gap-3"> | ||
| 172 | <View | ||
| 173 | className={ | ||
| 174 | props.selected | ||
| 175 | ? "h-7 w-7 items-center justify-center rounded-full bg-primary" | ||
| 176 | : "h-7 w-7 items-center justify-center" | ||
| 177 | } | ||
| 178 | > | ||
| 179 | {props.icon} | ||
| 180 | </View> | ||
| 181 | <View className="flex-1 gap-0.5"> | ||
| 182 | <Text className="text-[15px] font-t3-bold">{props.title}</Text> | ||
| 183 | {props.subtitle ? ( | ||
| 184 | <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={2}> | ||
| 185 | {props.subtitle} | ||
| 186 | </Text> | ||
| 187 | ) : null} | ||
| 188 | </View> | ||
| 189 | {"right" in props ? ( | ||
| 190 | props.right | ||
| 191 | ) : !props.disabled ? ( | ||
| 192 | <SymbolView name="chevron.right" size={13} tintColor={chevronColor} type="monochrome" /> | ||
| 193 | ) : null} | ||
| 194 | </View> | ||
| 195 | </Pressable> | ||
| 196 | ); | ||
| 197 | } | ||
| 198 | |||
| 199 | function PrimaryActionButton(props: { | ||
| 200 | readonly label: string; | ||
| 201 | readonly disabled?: boolean; | ||
| 202 | readonly loading?: boolean; | ||
| 203 | readonly onPress: () => void; | ||
| 204 | }) { | ||
| 205 | const primaryForeground = useThemeColor("--color-primary-foreground"); | ||
| 206 | |||
| 207 | return ( | ||
| 208 | <Pressable | ||
| 209 | disabled={props.disabled} | ||
| 210 | onPress={props.onPress} | ||
| 211 | className="h-12 items-center justify-center rounded-full bg-primary active:opacity-70 disabled:opacity-45" | ||
| 212 | > | ||
| 213 | {props.loading ? ( | ||
| 214 | <ActivityIndicator color={String(primaryForeground)} /> | ||
| 215 | ) : ( | ||
| 216 | <Text className="text-[14px] font-t3-bold text-primary-foreground">{props.label}</Text> | ||
| 217 | )} | ||
| 218 | </Pressable> | ||
| 219 | ); | ||
| 220 | } | ||
| 221 | |||
| 222 | function ProjectPathInput(props: { | ||
| 223 | readonly value: string; | ||
| 224 | readonly onChangeText: (value: string) => void; | ||
| 225 | readonly onSubmit: () => void; | ||
| 226 | }) { | ||
| 227 | return ( | ||
| 228 | <TextInput | ||
| 229 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-[15px] leading-[20px]" | ||
| 230 | value={props.value} | ||
| 231 | onChangeText={props.onChangeText} | ||
| 232 | autoCapitalize="none" | ||
| 233 | autoCorrect={false} | ||
| 234 | placeholder="~/projects/my-app" | ||
| 235 | returnKeyType="done" | ||
| 236 | onSubmitEditing={props.onSubmit} | ||
| 237 | /> | ||
| 238 | ); | ||
| 239 | } | ||
| 240 | |||
| 241 | function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> { | ||
| 242 | const { serverConfigByEnvironmentId } = useRemoteCatalog(); | ||
| 243 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 244 | |||
| 245 | return useMemo<ReadonlyArray<EnvironmentOption>>(() => { | ||
| 246 | const options = Object.values(savedConnectionsById).map((connection) => { | ||
| 247 | const config = serverConfigByEnvironmentId[connection.environmentId]; | ||
| 248 | return { | ||
| 249 | environmentId: connection.environmentId, | ||
| 250 | label: connection.environmentLabel, | ||
| 251 | platform: platformFromOs(config?.environment.platform.os ?? null), | ||
| 252 | baseDirectory: config?.settings.addProjectBaseDirectory ?? null, | ||
| 253 | }; | ||
| 254 | }); | ||
| 255 | return Arr.sort(options, environmentOptionOrder); | ||
| 256 | }, [savedConnectionsById, serverConfigByEnvironmentId]); | ||
| 257 | } | ||
| 258 | |||
| 259 | function useSelectedEnvironment(): { | ||
| 260 | readonly environmentOptions: ReadonlyArray<EnvironmentOption>; | ||
| 261 | readonly selectedEnvironment: EnvironmentOption | null; | ||
| 262 | readonly setSelectedEnvironmentId: (environmentId: EnvironmentId) => void; | ||
| 263 | } { | ||
| 264 | const router = useRouter(); | ||
| 265 | const params = useLocalSearchParams<{ environmentId?: string }>(); | ||
| 266 | const environmentOptions = useEnvironmentOptions(); | ||
| 267 | const requestedEnvironmentId = stringParam(params.environmentId) as EnvironmentId | null; | ||
| 268 | const selectedEnvironment = | ||
| 269 | environmentOptions.find( | ||
| 270 | (environment) => environment.environmentId === requestedEnvironmentId, | ||
| 271 | ) ?? | ||
| 272 | environmentOptions[0] ?? | ||
| 273 | null; | ||
| 274 | |||
| 275 | return { | ||
| 276 | environmentOptions, | ||
| 277 | selectedEnvironment, | ||
| 278 | setSelectedEnvironmentId: (environmentId) => router.setParams({ environmentId }), | ||
| 279 | }; | ||
| 280 | } | ||
| 281 | |||
| 282 | function EmptyEnvironmentState() { | ||
| 283 | const router = useRouter(); | ||
| 284 | |||
| 285 | return ( | ||
| 286 | <View className="items-center gap-3 rounded-2xl bg-card px-5 py-8"> | ||
| 287 | <Text className="text-center text-[17px] font-t3-bold">No environments connected</Text> | ||
| 288 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> | ||
| 289 | Add an environment before adding a project. | ||
| 290 | </Text> | ||
| 291 | <Pressable | ||
| 292 | onPress={() => router.replace("/connections/new")} | ||
| 293 | className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70" | ||
| 294 | > | ||
| 295 | <Text className="text-[13px] font-t3-bold text-primary-foreground">Add environment</Text> | ||
| 296 | </Pressable> | ||
| 297 | </View> | ||
| 298 | ); | ||
| 299 | } | ||
| 300 | |||
| 301 | function SourceControlRow(props: { | ||
| 302 | readonly source: AddProjectRemoteSource; | ||
| 303 | readonly selectedEnvironmentId: EnvironmentId; | ||
| 304 | readonly ready: boolean; | ||
| 305 | readonly hint: string; | ||
| 306 | readonly isFirst: boolean; | ||
| 307 | }) { | ||
| 308 | const router = useRouter(); | ||
| 309 | const iconColor = useThemeColor("--color-icon"); | ||
| 310 | const title = | ||
| 311 | props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`; | ||
| 312 | const subtitle = | ||
| 313 | props.source === "url" | ||
| 314 | ? "Clone from a remote URL" | ||
| 315 | : `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`; | ||
| 316 | const icon = | ||
| 317 | props.source === "url" ? ( | ||
| 318 | <SymbolView name="link" size={17} tintColor={iconColor} type="monochrome" /> | ||
| 319 | ) : ( | ||
| 320 | <SourceControlIcon kind={props.source} size={18} color={String(iconColor)} /> | ||
| 321 | ); | ||
| 322 | |||
| 323 | if (!props.ready) { | ||
| 324 | return ( | ||
| 325 | <ListRow title={title} subtitle={props.hint} icon={icon} disabled isFirst={props.isFirst} /> | ||
| 326 | ); | ||
| 327 | } | ||
| 328 | |||
| 329 | return ( | ||
| 330 | <ListRow | ||
| 331 | title={title} | ||
| 332 | subtitle={subtitle} | ||
| 333 | icon={icon} | ||
| 334 | isFirst={props.isFirst} | ||
| 335 | onPress={() => | ||
| 336 | router.push({ | ||
| 337 | pathname: "/new/add-project/repository", | ||
| 338 | params: { | ||
| 339 | environmentId: props.selectedEnvironmentId, | ||
| 340 | source: props.source, | ||
| 341 | }, | ||
| 342 | }) | ||
| 343 | } | ||
| 344 | /> | ||
| 345 | ); | ||
| 346 | } | ||
| 347 | |||
| 348 | export function AddProjectSourceScreen() { | ||
| 349 | const router = useRouter(); | ||
| 350 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 351 | const iconColor = useThemeColor("--color-icon"); | ||
| 352 | const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = | ||
| 353 | useSelectedEnvironment(); | ||
| 354 | const discoveryState = useSourceControlDiscovery(selectedEnvironment?.environmentId ?? null); | ||
| 355 | const readiness = useMemo( | ||
| 356 | () => buildAddProjectRemoteSourceReadiness(discoveryState.data), | ||
| 357 | [discoveryState.data], | ||
| 358 | ); | ||
| 359 | |||
| 360 | useEffect(() => { | ||
| 361 | if (!selectedEnvironment) return; | ||
| 362 | void refreshSourceControlDiscoveryForEnvironment(selectedEnvironment.environmentId); | ||
| 363 | }, [selectedEnvironment]); | ||
| 364 | |||
| 365 | return ( | ||
| 366 | <AddProjectShell title="Select source" closeToNew> | ||
| 367 | {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null} | ||
| 368 | |||
| 369 | {environmentOptions.length > 1 ? ( | ||
| 370 | <> | ||
| 371 | <SectionTitle>Connected environments</SectionTitle> | ||
| 372 | <ListSection> | ||
| 373 | {environmentOptions.map((environment, index) => ( | ||
| 374 | <ListRow | ||
| 375 | key={environment.environmentId} | ||
| 376 | title={environment.label} | ||
| 377 | subtitle={environment.environmentId} | ||
| 378 | icon={ | ||
| 379 | <SymbolView | ||
| 380 | name="server.rack" | ||
| 381 | size={17} | ||
| 382 | tintColor={iconColor} | ||
| 383 | type="monochrome" | ||
| 384 | /> | ||
| 385 | } | ||
| 386 | selected={environment.environmentId === selectedEnvironment?.environmentId} | ||
| 387 | isFirst={index === 0} | ||
| 388 | right={ | ||
| 389 | environment.environmentId === selectedEnvironment?.environmentId ? ( | ||
| 390 | <SymbolView | ||
| 391 | name="checkmark" | ||
| 392 | size={14} | ||
| 393 | tintColor={iconColor} | ||
| 394 | type="monochrome" | ||
| 395 | /> | ||
| 396 | ) : null | ||
| 397 | } | ||
| 398 | onPress={() => setSelectedEnvironmentId(environment.environmentId)} | ||
| 399 | /> | ||
| 400 | ))} | ||
| 401 | </ListSection> | ||
| 402 | </> | ||
| 403 | ) : null} | ||
| 404 | |||
| 405 | {selectedEnvironment ? ( | ||
| 406 | <> | ||
| 407 | <ListSection> | ||
| 408 | <ListRow | ||
| 409 | title="Local folder" | ||
| 410 | subtitle="Browse a folder on disk" | ||
| 411 | icon={ | ||
| 412 | <SymbolView | ||
| 413 | name="folder.badge.plus" | ||
| 414 | size={17} | ||
| 415 | tintColor={iconColor} | ||
| 416 | type="monochrome" | ||
| 417 | /> | ||
| 418 | } | ||
| 419 | isFirst | ||
| 420 | onPress={() => | ||
| 421 | router.push({ | ||
| 422 | pathname: "/new/add-project/local", | ||
| 423 | params: { environmentId: selectedEnvironment.environmentId }, | ||
| 424 | }) | ||
| 425 | } | ||
| 426 | /> | ||
| 427 | {(["url", ...sortAddProjectProviderSources(readiness)] as AddProjectRemoteSource[]).map( | ||
| 428 | (candidate) => ( | ||
| 429 | <SourceControlRow | ||
| 430 | key={candidate} | ||
| 431 | source={candidate} | ||
| 432 | selectedEnvironmentId={selectedEnvironment.environmentId} | ||
| 433 | ready={readiness[candidate].ready} | ||
| 434 | hint={ | ||
| 435 | readiness[candidate].ready | ||
| 436 | ? addProjectRemoteSourcePathHint(candidate) | ||
| 437 | : (readiness[candidate].hint ?? "") | ||
| 438 | } | ||
| 439 | isFirst={false} | ||
| 440 | /> | ||
| 441 | ), | ||
| 442 | )} | ||
| 443 | </ListSection> | ||
| 444 | {discoveryState.isPending ? <ActivityIndicator color={accentColor} /> : null} | ||
| 445 | </> | ||
| 446 | ) : null} | ||
| 447 | </AddProjectShell> | ||
| 448 | ); | ||
| 449 | } | ||
| 450 | |||
| 451 | function useCreateProject(environment: EnvironmentOption | null) { | ||
| 452 | const router = useRouter(); | ||
| 453 | const { projects } = useRemoteCatalog(); | ||
| 454 | |||
| 455 | return useCallback( | ||
| 456 | async (workspaceRoot: string) => { | ||
| 457 | if (!environment) return; | ||
| 458 | const client = getEnvironmentClient(environment.environmentId); | ||
| 459 | if (!client) throw new Error("Environment API is not available."); | ||
| 460 | |||
| 461 | const existing = findExistingAddProject({ | ||
| 462 | projects, | ||
| 463 | environmentId: environment.environmentId, | ||
| 464 | path: workspaceRoot, | ||
| 465 | }); | ||
| 466 | if (existing) { | ||
| 467 | Alert.alert("Project already exists", existing.title); | ||
| 468 | router.replace({ | ||
| 469 | pathname: "/new/draft", | ||
| 470 | params: { | ||
| 471 | environmentId: existing.environmentId, | ||
| 472 | projectId: existing.id, | ||
| 473 | title: existing.title, | ||
| 474 | }, | ||
| 475 | }); | ||
| 476 | return; | ||
| 477 | } | ||
| 478 | |||
| 479 | const projectId = ProjectId.make(uuidv4()); | ||
| 480 | await client.orchestration.dispatchCommand( | ||
| 481 | buildProjectCreateCommand({ | ||
| 482 | commandId: CommandId.make(uuidv4()), | ||
| 483 | projectId, | ||
| 484 | workspaceRoot, | ||
| 485 | createdAt: new Date().toISOString(), | ||
| 486 | }), | ||
| 487 | ); | ||
| 488 | router.replace({ | ||
| 489 | pathname: "/new/draft", | ||
| 490 | params: { | ||
| 491 | environmentId: environment.environmentId, | ||
| 492 | projectId, | ||
| 493 | title: inferProjectTitleFromPath(workspaceRoot), | ||
| 494 | }, | ||
| 495 | }); | ||
| 496 | }, | ||
| 497 | [environment, projects, router], | ||
| 498 | ); | ||
| 499 | } | ||
| 500 | |||
| 501 | function useEnvironmentFromParam(): EnvironmentOption | null { | ||
| 502 | const params = useLocalSearchParams<{ environmentId?: string }>(); | ||
| 503 | const environmentOptions = useEnvironmentOptions(); | ||
| 504 | const environmentId = stringParam(params.environmentId) as EnvironmentId | null; | ||
| 505 | return ( | ||
| 506 | environmentOptions.find((environment) => environment.environmentId === environmentId) ?? | ||
| 507 | environmentOptions[0] ?? | ||
| 508 | null | ||
| 509 | ); | ||
| 510 | } | ||
| 511 | |||
| 512 | export function AddProjectRepositoryScreen() { | ||
| 513 | const router = useRouter(); | ||
| 514 | const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); | ||
| 515 | const environment = useEnvironmentFromParam(); | ||
| 516 | const source = sourceFromParam(params.source); | ||
| 517 | const [repositoryInput, setRepositoryInput] = useState(""); | ||
| 518 | const [isSubmitting, setIsSubmitting] = useState(false); | ||
| 519 | const [error, setError] = useState<string | null>(null); | ||
| 520 | |||
| 521 | const lookupRepository = useCallback(async () => { | ||
| 522 | if (!environment || repositoryInput.trim().length === 0 || isSubmitting) return; | ||
| 523 | setError(null); | ||
| 524 | setIsSubmitting(true); | ||
| 525 | try { | ||
| 526 | const provider = addProjectRemoteSourceProvider(source); | ||
| 527 | if (!provider) { | ||
| 528 | const remoteUrl = repositoryInput.trim(); | ||
| 529 | router.push({ | ||
| 530 | pathname: "/new/add-project/destination", | ||
| 531 | params: { | ||
| 532 | environmentId: environment.environmentId, | ||
| 533 | source, | ||
| 534 | remoteUrl, | ||
| 535 | repositoryTitle: remoteUrl, | ||
| 536 | }, | ||
| 537 | }); | ||
| 538 | return; | ||
| 539 | } | ||
| 540 | |||
| 541 | const client = getEnvironmentClient(environment.environmentId); | ||
| 542 | if (!client) throw new Error("Environment API is not available."); | ||
| 543 | const repository = await client.sourceControl.lookupRepository({ | ||
| 544 | provider, | ||
| 545 | repository: repositoryInput.trim(), | ||
| 546 | }); | ||
| 547 | router.push({ | ||
| 548 | pathname: "/new/add-project/destination", | ||
| 549 | params: { | ||
| 550 | environmentId: environment.environmentId, | ||
| 551 | source, | ||
| 552 | remoteUrl: repository.sshUrl, | ||
| 553 | repositoryTitle: repository.nameWithOwner, | ||
| 554 | }, | ||
| 555 | }); | ||
| 556 | } catch (nextError) { | ||
| 557 | setError(errorMessage(nextError)); | ||
| 558 | } finally { | ||
| 559 | setIsSubmitting(false); | ||
| 560 | } | ||
| 561 | }, [environment, isSubmitting, repositoryInput, router, source]); | ||
| 562 | |||
| 563 | return ( | ||
| 564 | <AddProjectShell title={source === "url" ? "Git URL" : addProjectRemoteSourceLabel(source)}> | ||
| 565 | {error ? <ErrorBanner message={error} /> : null} | ||
| 566 | <TextInput | ||
| 567 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-[15px] leading-[20px]" | ||
| 568 | value={repositoryInput} | ||
| 569 | onChangeText={setRepositoryInput} | ||
| 570 | autoCapitalize="none" | ||
| 571 | autoCorrect={false} | ||
| 572 | placeholder={ | ||
| 573 | source === "url" | ||
| 574 | ? "https://github.com/org/repo.git" | ||
| 575 | : addProjectRemoteSourcePathHint(source) | ||
| 576 | } | ||
| 577 | returnKeyType="next" | ||
| 578 | onSubmitEditing={() => void lookupRepository()} | ||
| 579 | /> | ||
| 580 | <PrimaryActionButton | ||
| 581 | label={source === "url" ? "Continue" : "Lookup repository"} | ||
| 582 | disabled={isSubmitting || repositoryInput.trim().length === 0} | ||
| 583 | onPress={() => void lookupRepository()} | ||
| 584 | loading={isSubmitting} | ||
| 585 | /> | ||
| 586 | </AddProjectShell> | ||
| 587 | ); | ||
| 588 | } | ||
| 589 | |||
| 590 | function FolderBrowser(props: { | ||
| 591 | readonly environment: EnvironmentOption; | ||
| 592 | readonly pathInput: string; | ||
| 593 | readonly setPathInput: (path: string) => void; | ||
| 594 | }) { | ||
| 595 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 596 | const browseDirectoryPath = useMemo( | ||
| 597 | () => | ||
| 598 | isFilesystemBrowseQuery(props.pathInput, props.environment.platform) | ||
| 599 | ? getBrowseDirectoryPath(props.pathInput) | ||
| 600 | : "", | ||
| 601 | [props.environment.platform, props.pathInput], | ||
| 602 | ); | ||
| 603 | const browseFilterQuery = | ||
| 604 | browseDirectoryPath.length > 0 && !hasTrailingPathSeparator(props.pathInput) | ||
| 605 | ? getBrowseLeafPathSegment(props.pathInput).toLowerCase() | ||
| 606 | : ""; | ||
| 607 | const browseInput = useMemo( | ||
| 608 | () => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), | ||
| 609 | [browseDirectoryPath], | ||
| 610 | ); | ||
| 611 | const browseState = useFilesystemBrowse(props.environment.environmentId, browseInput); | ||
| 612 | const visibleBrowseEntries = useMemo( | ||
| 613 | () => | ||
| 614 | Arr.sort( | ||
| 615 | Arr.filter( | ||
| 616 | browseState.data?.entries ?? [], | ||
| 617 | (entry) => | ||
| 618 | !entry.name.startsWith(".") && entry.name.toLowerCase().startsWith(browseFilterQuery), | ||
| 619 | ), | ||
| 620 | browseEntryOrder, | ||
| 621 | ), | ||
| 622 | [browseFilterQuery, browseState.data?.entries], | ||
| 623 | ); | ||
| 624 | const parentBrowsePath = getBrowseParentPath(browseDirectoryPath); | ||
| 625 | const canBrowseUpPath = canNavigateUp(browseDirectoryPath); | ||
| 626 | |||
| 627 | return ( | ||
| 628 | <> | ||
| 629 | <SectionTitle>Browse folders</SectionTitle> | ||
| 630 | {browseState.error ? <ErrorBanner message={browseState.error} /> : null} | ||
| 631 | <ListSection> | ||
| 632 | {browseState.isPending && browseState.data === null ? ( | ||
| 633 | <View className="items-center py-5"> | ||
| 634 | <ActivityIndicator color={accentColor} /> | ||
| 635 | </View> | ||
| 636 | ) : null} | ||
| 637 | {canBrowseUpPath ? ( | ||
| 638 | <ListRow | ||
| 639 | title=".." | ||
| 640 | icon={ | ||
| 641 | <SymbolView | ||
| 642 | name="arrow.turn.left.up" | ||
| 643 | size={17} | ||
| 644 | tintColor={accentColor} | ||
| 645 | type="monochrome" | ||
| 646 | /> | ||
| 647 | } | ||
| 648 | isFirst | ||
| 649 | right={null} | ||
| 650 | onPress={() => { | ||
| 651 | if (parentBrowsePath) props.setPathInput(parentBrowsePath); | ||
| 652 | }} | ||
| 653 | /> | ||
| 654 | ) : null} | ||
| 655 | {visibleBrowseEntries.map((entry, index) => ( | ||
| 656 | <ListRow | ||
| 657 | key={entry.fullPath} | ||
| 658 | title={entry.name} | ||
| 659 | icon={<SymbolView name="folder" size={17} tintColor={accentColor} type="monochrome" />} | ||
| 660 | isFirst={index === 0 && !canBrowseUpPath} | ||
| 661 | right={null} | ||
| 662 | onPress={() => | ||
| 663 | props.setPathInput( | ||
| 664 | browseDirectoryPath.length > 0 | ||
| 665 | ? appendBrowsePathSegment(browseDirectoryPath, entry.name) | ||
| 666 | : ensureBrowseDirectoryPath(entry.fullPath), | ||
| 667 | ) | ||
| 668 | } | ||
| 669 | /> | ||
| 670 | ))} | ||
| 671 | </ListSection> | ||
| 672 | </> | ||
| 673 | ); | ||
| 674 | } | ||
| 675 | |||
| 676 | export function AddProjectLocalFolderScreen() { | ||
| 677 | const environment = useEnvironmentFromParam(); | ||
| 678 | const createProject = useCreateProject(environment); | ||
| 679 | const [pathInput, setPathInput] = useState(() => | ||
| 680 | getAddProjectInitialQuery(environment?.baseDirectory), | ||
| 681 | ); | ||
| 682 | const [isSubmitting, setIsSubmitting] = useState(false); | ||
| 683 | const [error, setError] = useState<string | null>(null); | ||
| 684 | |||
| 685 | useEffect(() => { | ||
| 686 | if (!environment) return; | ||
| 687 | setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); | ||
| 688 | }, [environment]); | ||
| 689 | |||
| 690 | const submitPath = useCallback(async () => { | ||
| 691 | if (!environment || isSubmitting) return; | ||
| 692 | setError(null); | ||
| 693 | const resolved = resolveAddProjectPath({ | ||
| 694 | rawPath: pathInput, | ||
| 695 | currentProjectCwd: null, | ||
| 696 | platform: environment.platform, | ||
| 697 | }); | ||
| 698 | if (!resolved.ok) { | ||
| 699 | setError(resolved.error); | ||
| 700 | return; | ||
| 701 | } | ||
| 702 | |||
| 703 | setIsSubmitting(true); | ||
| 704 | try { | ||
| 705 | await createProject(resolved.path); | ||
| 706 | } catch (nextError) { | ||
| 707 | setError(errorMessage(nextError)); | ||
| 708 | } finally { | ||
| 709 | setIsSubmitting(false); | ||
| 710 | } | ||
| 711 | }, [createProject, environment, isSubmitting, pathInput]); | ||
| 712 | |||
| 713 | return ( | ||
| 714 | <AddProjectShell title="Local folder"> | ||
| 715 | {error ? <ErrorBanner message={error} /> : null} | ||
| 716 | {environment ? ( | ||
| 717 | <> | ||
| 718 | <ProjectPathInput | ||
| 719 | value={pathInput} | ||
| 720 | onChangeText={setPathInput} | ||
| 721 | onSubmit={() => void submitPath()} | ||
| 722 | /> | ||
| 723 | <PrimaryActionButton | ||
| 724 | label="Add project" | ||
| 725 | disabled={isSubmitting} | ||
| 726 | onPress={() => void submitPath()} | ||
| 727 | loading={isSubmitting} | ||
| 728 | /> | ||
| 729 | <FolderBrowser | ||
| 730 | environment={environment} | ||
| 731 | pathInput={pathInput} | ||
| 732 | setPathInput={setPathInput} | ||
| 733 | /> | ||
| 734 | </> | ||
| 735 | ) : ( | ||
| 736 | <EmptyEnvironmentState /> | ||
| 737 | )} | ||
| 738 | </AddProjectShell> | ||
| 739 | ); | ||
| 740 | } | ||
| 741 | |||
| 742 | export function AddProjectDestinationScreen() { | ||
| 743 | const params = useLocalSearchParams<{ | ||
| 744 | environmentId?: string; | ||
| 745 | remoteUrl?: string; | ||
| 746 | repositoryTitle?: string; | ||
| 747 | }>(); | ||
| 748 | const environment = useEnvironmentFromParam(); | ||
| 749 | const createProject = useCreateProject(environment); | ||
| 750 | const remoteUrl = stringParam(params.remoteUrl); | ||
| 751 | const repositoryTitle = stringParam(params.repositoryTitle); | ||
| 752 | const [pathInput, setPathInput] = useState(() => | ||
| 753 | getAddProjectInitialQuery(environment?.baseDirectory), | ||
| 754 | ); | ||
| 755 | const [isSubmitting, setIsSubmitting] = useState(false); | ||
| 756 | const [error, setError] = useState<string | null>(null); | ||
| 757 | |||
| 758 | useEffect(() => { | ||
| 759 | if (!environment) return; | ||
| 760 | setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); | ||
| 761 | }, [environment]); | ||
| 762 | |||
| 763 | const submitPath = useCallback(async () => { | ||
| 764 | if (!environment || !remoteUrl || isSubmitting) return; | ||
| 765 | setError(null); | ||
| 766 | const resolved = resolveAddProjectPath({ | ||
| 767 | rawPath: pathInput, | ||
| 768 | currentProjectCwd: null, | ||
| 769 | platform: environment.platform, | ||
| 770 | }); | ||
| 771 | if (!resolved.ok) { | ||
| 772 | setError(resolved.error); | ||
| 773 | return; | ||
| 774 | } | ||
| 775 | |||
| 776 | setIsSubmitting(true); | ||
| 777 | try { | ||
| 778 | const client = getEnvironmentClient(environment.environmentId); | ||
| 779 | if (!client) throw new Error("Environment API is not available."); | ||
| 780 | const result = await client.sourceControl.cloneRepository({ | ||
| 781 | remoteUrl, | ||
| 782 | destinationPath: resolved.path, | ||
| 783 | }); | ||
| 784 | await createProject(result.cwd); | ||
| 785 | } catch (nextError) { | ||
| 786 | setError(errorMessage(nextError)); | ||
| 787 | } finally { | ||
| 788 | setIsSubmitting(false); | ||
| 789 | } | ||
| 790 | }, [createProject, environment, isSubmitting, pathInput, remoteUrl]); | ||
| 791 | |||
| 792 | return ( | ||
| 793 | <AddProjectShell title="Clone destination"> | ||
| 794 | {error ? <ErrorBanner message={error} /> : null} | ||
| 795 | {repositoryTitle ? ( | ||
| 796 | <View className="rounded-[24px] bg-card px-4 py-3"> | ||
| 797 | <Text className="text-[14px] font-t3-bold">{repositoryTitle}</Text> | ||
| 798 | <Text className="mt-0.5 text-[12px] text-foreground-muted" numberOfLines={2}> | ||
| 799 | {remoteUrl} | ||
| 800 | </Text> | ||
| 801 | </View> | ||
| 802 | ) : null} | ||
| 803 | {environment ? ( | ||
| 804 | <> | ||
| 805 | <ProjectPathInput | ||
| 806 | value={pathInput} | ||
| 807 | onChangeText={setPathInput} | ||
| 808 | onSubmit={() => void submitPath()} | ||
| 809 | /> | ||
| 810 | <PrimaryActionButton | ||
| 811 | label="Clone project" | ||
| 812 | disabled={isSubmitting || !remoteUrl} | ||
| 813 | onPress={() => void submitPath()} | ||
| 814 | loading={isSubmitting} | ||
| 815 | /> | ||
| 816 | <FolderBrowser | ||
| 817 | environment={environment} | ||
| 818 | pathInput={pathInput} | ||
| 819 | setPathInput={setPathInput} | ||
| 820 | /> | ||
| 821 | </> | ||
| 822 | ) : ( | ||
| 823 | <EmptyEnvironmentState /> | ||
| 824 | )} | ||
| 825 | </AddProjectShell> | ||
| 826 | ); | ||
| 827 | } | ||
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx created+318| ... | @@ -0,0 +1,318 @@ | ||
| 1 | import { useLocalSearchParams, useRouter } from "expo-router"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import { TextInputWrapper } from "expo-paste-input"; | ||
| 4 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 5 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 6 | import { Pressable, ScrollView, View, useColorScheme, useWindowDimensions } from "react-native"; | ||
| 7 | import { KeyboardAvoidingView } from "react-native-keyboard-controller"; | ||
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 9 | import ImageViewing from "react-native-image-viewing"; | ||
| 10 | |||
| 11 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 12 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | ||
| 13 | import { ControlPill } from "../../components/ControlPill"; | ||
| 14 | import { cn } from "../../lib/cn"; | ||
| 15 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | ||
| 16 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; | ||
| 17 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 18 | import { useNativePaste } from "../../lib/useNativePaste"; | ||
| 19 | import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; | ||
| 20 | import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state"; | ||
| 21 | import { | ||
| 22 | clearReviewCommentTarget, | ||
| 23 | formatReviewCommentContext, | ||
| 24 | getReviewUnifiedLineNumber, | ||
| 25 | getSelectedReviewCommentLines, | ||
| 26 | useReviewCommentTarget, | ||
| 27 | } from "./reviewCommentSelection"; | ||
| 28 | import { | ||
| 29 | changeTone, | ||
| 30 | DiffTokenText, | ||
| 31 | REVIEW_DIFF_LINE_HEIGHT, | ||
| 32 | REVIEW_MONO_FONT_FAMILY, | ||
| 33 | ReviewChangeBar, | ||
| 34 | } from "./reviewDiffRendering"; | ||
| 35 | import { | ||
| 36 | highlightReviewSelectedLines, | ||
| 37 | type ReviewDiffTheme, | ||
| 38 | type ReviewHighlightedToken, | ||
| 39 | } from "./shikiReviewHighlighter"; | ||
| 40 | |||
| 41 | const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5; | ||
| 42 | |||
| 43 | export function ReviewCommentComposerSheet() { | ||
| 44 | const router = useRouter(); | ||
| 45 | const insets = useSafeAreaInsets(); | ||
| 46 | const { width } = useWindowDimensions(); | ||
| 47 | const colorScheme = useColorScheme(); | ||
| 48 | const iconTint = String(useThemeColor("--color-icon")); | ||
| 49 | const target = useReviewCommentTarget(); | ||
| 50 | const { environmentId, threadId } = useLocalSearchParams<{ | ||
| 51 | environmentId: EnvironmentId; | ||
| 52 | threadId: ThreadId; | ||
| 53 | }>(); | ||
| 54 | const [commentText, setCommentText] = useState(""); | ||
| 55 | const [highlightedLinesById, setHighlightedLinesById] = useState< | ||
| 56 | Record<string, ReadonlyArray<ReviewHighlightedToken>> | ||
| 57 | >({}); | ||
| 58 | const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); | ||
| 59 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | ||
| 60 | |||
| 61 | const selectedLines = useMemo( | ||
| 62 | () => (target ? getSelectedReviewCommentLines(target) : []), | ||
| 63 | [target], | ||
| 64 | ); | ||
| 65 | const firstLine = selectedLines[0] ?? null; | ||
| 66 | const lastLine = selectedLines[selectedLines.length - 1] ?? null; | ||
| 67 | const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null; | ||
| 68 | const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null; | ||
| 69 | const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; | ||
| 70 | const canSubmit = | ||
| 71 | commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId; | ||
| 72 | const selectionLabel = | ||
| 73 | selectedLines.length === 1 | ||
| 74 | ? firstNumber !== null | ||
| 75 | ? `Line ${firstNumber}` | ||
| 76 | : "File comment" | ||
| 77 | : firstNumber !== null && lastNumber !== null | ||
| 78 | ? `Lines ${firstNumber}-${lastNumber}` | ||
| 79 | : `${selectedLines.length} lines selected`; | ||
| 80 | const previewHeight = Math.max( | ||
| 81 | Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT, | ||
| 82 | REVIEW_DIFF_LINE_HEIGHT, | ||
| 83 | ); | ||
| 84 | const previewViewportWidth = Math.max(width - 40, 280); | ||
| 85 | const dismissComposer = useCallback(() => { | ||
| 86 | clearReviewCommentTarget(); | ||
| 87 | router.dismiss(); | ||
| 88 | }, [router]); | ||
| 89 | const handleNativePaste = useNativePaste((uris) => { | ||
| 90 | void (async () => { | ||
| 91 | try { | ||
| 92 | const images = await convertPastedImagesToAttachments({ | ||
| 93 | uris, | ||
| 94 | existingCount: attachments.length, | ||
| 95 | }); | ||
| 96 | if (images.length > 0) { | ||
| 97 | setAttachments((current) => [...current, ...images]); | ||
| 98 | } | ||
| 99 | } catch (error) { | ||
| 100 | console.error("[review comment] error converting pasted images", error); | ||
| 101 | } | ||
| 102 | })(); | ||
| 103 | }); | ||
| 104 | |||
| 105 | useEffect(() => { | ||
| 106 | if (!target || selectedLines.length === 0) { | ||
| 107 | setHighlightedLinesById({}); | ||
| 108 | return; | ||
| 109 | } | ||
| 110 | |||
| 111 | let cancelled = false; | ||
| 112 | void highlightReviewSelectedLines({ | ||
| 113 | filePath: target.filePath, | ||
| 114 | lines: selectedLines, | ||
| 115 | theme: selectedTheme, | ||
| 116 | }) | ||
| 117 | .then((next) => { | ||
| 118 | if (!cancelled) { | ||
| 119 | setHighlightedLinesById(next); | ||
| 120 | } | ||
| 121 | }) | ||
| 122 | .catch(() => { | ||
| 123 | if (!cancelled) { | ||
| 124 | setHighlightedLinesById({}); | ||
| 125 | } | ||
| 126 | }); | ||
| 127 | |||
| 128 | return () => { | ||
| 129 | cancelled = true; | ||
| 130 | }; | ||
| 131 | }, [selectedLines, selectedTheme, target]); | ||
| 132 | |||
| 133 | async function handlePickImages(): Promise<void> { | ||
| 134 | const result = await pickComposerImages({ existingCount: attachments.length }); | ||
| 135 | if (result.images.length > 0) { | ||
| 136 | setAttachments((current) => [...current, ...result.images]); | ||
| 137 | } | ||
| 138 | if (result.error) { | ||
| 139 | setPendingConnectionError(result.error); | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | return ( | ||
| 144 | <View style={{ flex: 1 }}> | ||
| 145 | <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}> | ||
| 146 | <View | ||
| 147 | style={{ | ||
| 148 | flex: 1, | ||
| 149 | paddingHorizontal: 20, | ||
| 150 | paddingTop: 8, | ||
| 151 | paddingBottom: target ? 0 : Math.max(insets.bottom, 18), | ||
| 152 | }} | ||
| 153 | > | ||
| 154 | <View className="flex-row items-center justify-between py-2"> | ||
| 155 | <Pressable | ||
| 156 | className="bg-subtle h-12 w-12 items-center justify-center rounded-full" | ||
| 157 | onPress={dismissComposer} | ||
| 158 | > | ||
| 159 | <SymbolView name="xmark" size={18} tintColor={iconTint} type="monochrome" /> | ||
| 160 | </Pressable> | ||
| 161 | |||
| 162 | <Text className="text-[18px] font-t3-bold text-foreground">Add Comment</Text> | ||
| 163 | |||
| 164 | <View className="h-12 w-12" /> | ||
| 165 | </View> | ||
| 166 | |||
| 167 | {!target ? ( | ||
| 168 | <View className="rounded-[22px] border border-border bg-card px-4 py-5"> | ||
| 169 | <Text className="text-[15px] font-t3-bold text-foreground">No selection</Text> | ||
| 170 | <Text className="mt-1 text-[13px] leading-[19px] text-foreground-muted"> | ||
| 171 | Select a diff line or range first. | ||
| 172 | </Text> | ||
| 173 | </View> | ||
| 174 | ) : ( | ||
| 175 | <View className="min-h-0 flex-1 gap-4"> | ||
| 176 | <View className="gap-1 px-1"> | ||
| 177 | <Text className="text-[11px] font-t3-bold uppercase text-foreground-muted"> | ||
| 178 | {selectionLabel} | ||
| 179 | </Text> | ||
| 180 | <Text | ||
| 181 | className="font-mono text-[12px] leading-[17px] text-foreground-muted" | ||
| 182 | ellipsizeMode="middle" | ||
| 183 | numberOfLines={2} | ||
| 184 | > | ||
| 185 | {target.filePath} | ||
| 186 | </Text> | ||
| 187 | </View> | ||
| 188 | |||
| 189 | <View className="overflow-hidden rounded-[22px] border border-border bg-card"> | ||
| 190 | <ScrollView | ||
| 191 | horizontal | ||
| 192 | bounces={false} | ||
| 193 | keyboardShouldPersistTaps="always" | ||
| 194 | showsHorizontalScrollIndicator={false} | ||
| 195 | > | ||
| 196 | <ScrollView | ||
| 197 | bounces={false} | ||
| 198 | scrollEnabled={selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES} | ||
| 199 | nestedScrollEnabled | ||
| 200 | keyboardShouldPersistTaps="always" | ||
| 201 | showsVerticalScrollIndicator={ | ||
| 202 | selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES | ||
| 203 | } | ||
| 204 | style={{ height: previewHeight }} | ||
| 205 | > | ||
| 206 | <View style={{ minWidth: previewViewportWidth }}> | ||
| 207 | {selectedLines.map((line) => { | ||
| 208 | const lineNumber = getReviewUnifiedLineNumber(line); | ||
| 209 | |||
| 210 | return ( | ||
| 211 | <View | ||
| 212 | key={line.id} | ||
| 213 | className={cn("flex-row items-start", changeTone(line.change))} | ||
| 214 | style={{ height: REVIEW_DIFF_LINE_HEIGHT }} | ||
| 215 | > | ||
| 216 | <ReviewChangeBar change={line.change} /> | ||
| 217 | <Text | ||
| 218 | className="w-9 py-1 pr-1 text-right text-[11px] font-t3-medium text-foreground-muted" | ||
| 219 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | ||
| 220 | > | ||
| 221 | {lineNumber ?? ""} | ||
| 222 | </Text> | ||
| 223 | <View className="min-w-0 flex-1 shrink-0 px-1 py-1"> | ||
| 224 | <DiffTokenText | ||
| 225 | fallback={line.content} | ||
| 226 | tokens={highlightedLinesById[line.id] ?? null} | ||
| 227 | change={line.change} | ||
| 228 | /> | ||
| 229 | </View> | ||
| 230 | </View> | ||
| 231 | ); | ||
| 232 | })} | ||
| 233 | </View> | ||
| 234 | </ScrollView> | ||
| 235 | </ScrollView> | ||
| 236 | </View> | ||
| 237 | |||
| 238 | <View className="min-h-0 flex-1 gap-2"> | ||
| 239 | <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text> | ||
| 240 | <View className="min-h-[132px] flex-1 overflow-hidden rounded-[20px] border border-border bg-card"> | ||
| 241 | <View className="flex-1 px-4 pt-3.5"> | ||
| 242 | <TextInputWrapper onPaste={handleNativePaste} style={{ flex: 1 }}> | ||
| 243 | <TextInput | ||
| 244 | autoFocus | ||
| 245 | multiline | ||
| 246 | placeholder="Leave a comment..." | ||
| 247 | textAlignVertical="top" | ||
| 248 | value={commentText} | ||
| 249 | onChangeText={setCommentText} | ||
| 250 | className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-[15px]" | ||
| 251 | style={{ flex: 1 }} | ||
| 252 | /> | ||
| 253 | </TextInputWrapper> | ||
| 254 | </View> | ||
| 255 | {attachments.length > 0 ? ( | ||
| 256 | <View className="px-4 pb-3 pt-2"> | ||
| 257 | <ComposerAttachmentStrip | ||
| 258 | attachments={attachments} | ||
| 259 | imageBorderRadius={16} | ||
| 260 | imageSize={60} | ||
| 261 | onPressImage={setPreviewImageUri} | ||
| 262 | removeButtonPlacement="gutter" | ||
| 263 | onRemove={(imageId) => { | ||
| 264 | setAttachments((current) => | ||
| 265 | current.filter((image) => image.id !== imageId), | ||
| 266 | ); | ||
| 267 | }} | ||
| 268 | /> | ||
| 269 | </View> | ||
| 270 | ) : null} | ||
| 271 | </View> | ||
| 272 | </View> | ||
| 273 | </View> | ||
| 274 | )} | ||
| 275 | </View> | ||
| 276 | {target ? ( | ||
| 277 | <View className="flex-row items-center gap-3 bg-sheet px-5 py-2"> | ||
| 278 | <ControlPill | ||
| 279 | accessibilityLabel="Add image" | ||
| 280 | icon="plus" | ||
| 281 | onPress={() => void handlePickImages()} | ||
| 282 | /> | ||
| 283 | <View className="flex-1" /> | ||
| 284 | <ControlPill | ||
| 285 | accessibilityLabel="Comment" | ||
| 286 | icon="arrow.up" | ||
| 287 | label="Comment" | ||
| 288 | variant="primary" | ||
| 289 | disabled={!canSubmit} | ||
| 290 | onPress={() => { | ||
| 291 | if (!target || !environmentId || !threadId || commentText.trim().length === 0) { | ||
| 292 | return; | ||
| 293 | } | ||
| 294 | |||
| 295 | appendReviewCommentToDraft({ | ||
| 296 | environmentId, | ||
| 297 | threadId, | ||
| 298 | text: formatReviewCommentContext(target, commentText), | ||
| 299 | attachments, | ||
| 300 | }); | ||
| 301 | setAttachments([]); | ||
| 302 | dismissComposer(); | ||
| 303 | }} | ||
| 304 | /> | ||
| 305 | </View> | ||
| 306 | ) : null} | ||
| 307 | </KeyboardAvoidingView> | ||
| 308 | <ImageViewing | ||
| 309 | images={previewImageUri ? [{ uri: previewImageUri }] : []} | ||
| 310 | imageIndex={0} | ||
| 311 | visible={previewImageUri !== null} | ||
| 312 | onRequestClose={() => setPreviewImageUri(null)} | ||
| 313 | swipeToCloseEnabled | ||
| 314 | doubleTapToZoomEnabled | ||
| 315 | /> | ||
| 316 | </View> | ||
| 317 | ); | ||
| 318 | } | ||
apps/mobile/src/features/review/ReviewHighlighterProvider.tsx created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import { createContext, type ReactNode, useContext, 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 | } | ||
| 21 | |||
| 22 | export function useReviewHighlighterStatus(): ReviewHighlighterState { | ||
| 23 | return useContext(ReviewHighlighterContext); | ||
| 24 | } | ||
apps/mobile/src/features/review/ReviewSheet.tsx created+432| ... | @@ -0,0 +1,432 @@ | ||
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { useLocalSearchParams } from "expo-router"; | ||
| 3 | import Stack from "expo-router/stack"; | ||
| 4 | import { SymbolView } from "expo-symbols"; | ||
| 5 | import { memo, type ReactElement, useCallback, useMemo } from "react"; | ||
| 6 | import { | ||
| 7 | ActivityIndicator, | ||
| 8 | Pressable, | ||
| 9 | ScrollView, | ||
| 10 | type NativeSyntheticEvent, | ||
| 11 | Text as NativeText, | ||
| 12 | StyleSheet, | ||
| 13 | useColorScheme, | ||
| 14 | View, | ||
| 15 | } from "react-native"; | ||
| 16 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 17 | |||
| 18 | import { AppText as Text } from "../../components/AppText"; | ||
| 19 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 20 | import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; | ||
| 21 | import { useReviewCacheForThread } from "./reviewState"; | ||
| 22 | import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; | ||
| 23 | import { | ||
| 24 | NATIVE_REVIEW_DIFF_CONTENT_WIDTH, | ||
| 25 | NATIVE_REVIEW_DIFF_ROW_HEIGHT, | ||
| 26 | } from "./nativeReviewDiffAdapter"; | ||
| 27 | import { useReviewDiffData } from "./useReviewDiffData"; | ||
| 28 | import { useReviewFileVisibility } from "./reviewFileVisibility"; | ||
| 29 | import { useReviewSections } from "./useReviewSections"; | ||
| 30 | import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge"; | ||
| 31 | import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController"; | ||
| 32 | |||
| 33 | const IOS_NAV_BAR_HEIGHT = 44; | ||
| 34 | const REVIEW_HEADER_SPACING = 0; | ||
| 35 | |||
| 36 | const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { | ||
| 37 | return ( | ||
| 38 | <View className="border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-900/60 dark:bg-amber-950/40"> | ||
| 39 | <Text className="text-[12px] font-t3-bold uppercase text-amber-700 dark:text-amber-300"> | ||
| 40 | Partial diff | ||
| 41 | </Text> | ||
| 42 | <Text className="text-[12px] leading-[18px] text-amber-800 dark:text-amber-200"> | ||
| 43 | {props.notice} | ||
| 44 | </Text> | ||
| 45 | </View> | ||
| 46 | ); | ||
| 47 | }); | ||
| 48 | |||
| 49 | function ReviewSelectionActionBar(props: { | ||
| 50 | readonly bottomInset: number; | ||
| 51 | readonly title: string | null; | ||
| 52 | readonly onOpenComment: (() => void) | null; | ||
| 53 | readonly onClear: () => void; | ||
| 54 | }) { | ||
| 55 | if (!props.title) { | ||
| 56 | return null; | ||
| 57 | } | ||
| 58 | |||
| 59 | const content = ( | ||
| 60 | <> | ||
| 61 | <SymbolView | ||
| 62 | name={props.onOpenComment ? "text.bubble" : "line.3.horizontal.decrease.circle"} | ||
| 63 | size={16} | ||
| 64 | tintColor="#ffffff" | ||
| 65 | type="monochrome" | ||
| 66 | /> | ||
| 67 | <Text className="text-[15px] font-t3-bold text-white">{props.title}</Text> | ||
| 68 | </> | ||
| 69 | ); | ||
| 70 | |||
| 71 | return ( | ||
| 72 | <View | ||
| 73 | pointerEvents="box-none" | ||
| 74 | style={{ | ||
| 75 | position: "absolute", | ||
| 76 | left: 18, | ||
| 77 | right: 18, | ||
| 78 | bottom: Math.max(props.bottomInset, 10) + 18, | ||
| 79 | flexDirection: "row", | ||
| 80 | justifyContent: "center", | ||
| 81 | gap: 10, | ||
| 82 | }} | ||
| 83 | > | ||
| 84 | {props.onOpenComment ? ( | ||
| 85 | <Pressable | ||
| 86 | className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-blue-600 px-5" | ||
| 87 | onPress={props.onOpenComment} | ||
| 88 | > | ||
| 89 | {content} | ||
| 90 | </Pressable> | ||
| 91 | ) : ( | ||
| 92 | <View className="h-12 flex-1 flex-row items-center justify-center gap-2 rounded-full bg-blue-600 px-5"> | ||
| 93 | {content} | ||
| 94 | </View> | ||
| 95 | )} | ||
| 96 | |||
| 97 | <Pressable | ||
| 98 | className="h-12 w-12 items-center justify-center rounded-full bg-blue-600" | ||
| 99 | onPress={props.onClear} | ||
| 100 | > | ||
| 101 | <SymbolView name="xmark" size={16} tintColor="#ffffff" type="monochrome" /> | ||
| 102 | </Pressable> | ||
| 103 | </View> | ||
| 104 | ); | ||
| 105 | } | ||
| 106 | |||
| 107 | export function ReviewSheet() { | ||
| 108 | const insets = useSafeAreaInsets(); | ||
| 109 | const colorScheme = useColorScheme(); | ||
| 110 | const headerForeground = String(useThemeColor("--color-foreground")); | ||
| 111 | const headerMuted = String(useThemeColor("--color-foreground-muted")); | ||
| 112 | const headerIcon = String(useThemeColor("--color-icon")); | ||
| 113 | const { environmentId, threadId } = useLocalSearchParams<{ | ||
| 114 | environmentId: EnvironmentId; | ||
| 115 | threadId: ThreadId; | ||
| 116 | }>(); | ||
| 117 | const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); | ||
| 118 | const reviewCache = useReviewCacheForThread({ environmentId, threadId }); | ||
| 119 | const selectedTheme = colorScheme === "dark" ? "dark" : "light"; | ||
| 120 | const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; | ||
| 121 | const { | ||
| 122 | error, | ||
| 123 | loadingGitDiffs, | ||
| 124 | loadingTurnIds, | ||
| 125 | reviewSections, | ||
| 126 | selectedSection, | ||
| 127 | refreshSelectedSection, | ||
| 128 | selectSection, | ||
| 129 | } = useReviewSections({ environmentId, threadId, reviewCache }); | ||
| 130 | const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = | ||
| 131 | useReviewDiffData({ | ||
| 132 | threadKey: reviewCache.threadKey, | ||
| 133 | selectedSection, | ||
| 134 | draftMessage, | ||
| 135 | }); | ||
| 136 | const NativeReviewDiffView = resolveNativeReviewDiffView()!; | ||
| 137 | const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : []; | ||
| 138 | const fileVisibility = useReviewFileVisibility({ | ||
| 139 | threadKey: reviewCache.threadKey, | ||
| 140 | sectionId: selectedSection?.id ?? null, | ||
| 141 | files: reviewFiles, | ||
| 142 | cachedExpandedFileIds: selectedSection?.id | ||
| 143 | ? reviewCache.expandedFileIdsBySection[selectedSection.id] | ||
| 144 | : undefined, | ||
| 145 | cachedViewedFileIds: selectedSection?.id | ||
| 146 | ? reviewCache.viewedFileIdsBySection[selectedSection.id] | ||
| 147 | : undefined, | ||
| 148 | }); | ||
| 149 | const { collapsedFileIds, toggleExpandedFile, toggleViewedFile, viewedFileIds } = fileVisibility; | ||
| 150 | const commentSelection = useReviewCommentSelectionController({ | ||
| 151 | environmentId, | ||
| 152 | threadId, | ||
| 153 | selectedSection, | ||
| 154 | nativeReviewDiffData, | ||
| 155 | }); | ||
| 156 | const nativeBridge = useNativeReviewDiffBridge({ | ||
| 157 | threadKey: reviewCache.threadKey, | ||
| 158 | sectionId: selectedSection?.id ?? null, | ||
| 159 | diff: selectedSection?.diff, | ||
| 160 | data: nativeReviewDiffData, | ||
| 161 | scheme: selectedTheme, | ||
| 162 | collapsedFileIds, | ||
| 163 | viewedFileIds, | ||
| 164 | selectedRowIds: commentSelection.selectedRowIds, | ||
| 165 | canHighlight: parsedDiff.kind === "files", | ||
| 166 | }); | ||
| 167 | |||
| 168 | const handleNativeToggleFile = useCallback( | ||
| 169 | (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => { | ||
| 170 | const { fileId } = event.nativeEvent; | ||
| 171 | if (fileId) { | ||
| 172 | toggleExpandedFile(fileId); | ||
| 173 | } | ||
| 174 | }, | ||
| 175 | [toggleExpandedFile], | ||
| 176 | ); | ||
| 177 | |||
| 178 | const handleNativeToggleViewedFile = useCallback( | ||
| 179 | (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => { | ||
| 180 | const { fileId } = event.nativeEvent; | ||
| 181 | if (fileId) { | ||
| 182 | toggleViewedFile(fileId); | ||
| 183 | } | ||
| 184 | }, | ||
| 185 | [toggleViewedFile], | ||
| 186 | ); | ||
| 187 | |||
| 188 | const parsedDiffNotice = | ||
| 189 | parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; | ||
| 190 | |||
| 191 | const listHeader = useMemo(() => { | ||
| 192 | const children: ReactElement[] = []; | ||
| 193 | |||
| 194 | if (error) { | ||
| 195 | children.push( | ||
| 196 | <View key="review-error" className="border-b border-border bg-card px-4 py-3"> | ||
| 197 | <Text className="text-[13px] font-t3-bold text-foreground">Review unavailable</Text> | ||
| 198 | <Text className="text-[12px] leading-[18px] text-foreground-muted">{error}</Text> | ||
| 199 | </View>, | ||
| 200 | ); | ||
| 201 | } | ||
| 202 | |||
| 203 | if (parsedDiffNotice) { | ||
| 204 | children.push(<ReviewNotice key="review-notice" notice={parsedDiffNotice} />); | ||
| 205 | } | ||
| 206 | |||
| 207 | if (children.length === 0) { | ||
| 208 | return null; | ||
| 209 | } | ||
| 210 | |||
| 211 | return <>{children}</>; | ||
| 212 | }, [error, parsedDiffNotice]); | ||
| 213 | |||
| 214 | return ( | ||
| 215 | <> | ||
| 216 | <Stack.Screen | ||
| 217 | options={{ | ||
| 218 | headerTransparent: true, | ||
| 219 | headerShadowVisible: false, | ||
| 220 | headerTintColor: headerIcon, | ||
| 221 | headerStyle: { | ||
| 222 | backgroundColor: "transparent", | ||
| 223 | }, | ||
| 224 | headerTitle: () => ( | ||
| 225 | <View style={{ alignItems: "center" }}> | ||
| 226 | <NativeText | ||
| 227 | numberOfLines={1} | ||
| 228 | style={{ | ||
| 229 | fontFamily: "DMSans_700Bold", | ||
| 230 | fontSize: 18, | ||
| 231 | fontWeight: "900", | ||
| 232 | color: headerForeground, | ||
| 233 | letterSpacing: -0.4, | ||
| 234 | }} | ||
| 235 | > | ||
| 236 | Files Changed | ||
| 237 | </NativeText> | ||
| 238 | <View | ||
| 239 | style={{ | ||
| 240 | flexDirection: "row", | ||
| 241 | alignItems: "center", | ||
| 242 | justifyContent: "center", | ||
| 243 | gap: 6, | ||
| 244 | flexWrap: "wrap", | ||
| 245 | }} | ||
| 246 | > | ||
| 247 | {headerDiffSummary.additions && headerDiffSummary.deletions ? ( | ||
| 248 | <> | ||
| 249 | <NativeText | ||
| 250 | style={{ | ||
| 251 | fontFamily: "DMSans_700Bold", | ||
| 252 | fontSize: 12, | ||
| 253 | fontWeight: "700", | ||
| 254 | color: "#16a34a", | ||
| 255 | }} | ||
| 256 | > | ||
| 257 | {headerDiffSummary.additions} | ||
| 258 | </NativeText> | ||
| 259 | <NativeText | ||
| 260 | style={{ | ||
| 261 | fontFamily: "DMSans_700Bold", | ||
| 262 | fontSize: 12, | ||
| 263 | fontWeight: "700", | ||
| 264 | color: "#e11d48", | ||
| 265 | }} | ||
| 266 | > | ||
| 267 | {headerDiffSummary.deletions} | ||
| 268 | </NativeText> | ||
| 269 | {pendingReviewCommentCount > 0 ? ( | ||
| 270 | <NativeText | ||
| 271 | style={{ | ||
| 272 | fontFamily: "DMSans_700Bold", | ||
| 273 | fontSize: 12, | ||
| 274 | fontWeight: "700", | ||
| 275 | color: "#b45309", | ||
| 276 | }} | ||
| 277 | > | ||
| 278 | {pendingReviewCommentCount} pending | ||
| 279 | </NativeText> | ||
| 280 | ) : null} | ||
| 281 | </> | ||
| 282 | ) : ( | ||
| 283 | <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}> | ||
| 284 | <NativeText | ||
| 285 | numberOfLines={1} | ||
| 286 | style={{ | ||
| 287 | fontFamily: "DMSans_700Bold", | ||
| 288 | fontSize: 12, | ||
| 289 | fontWeight: "700", | ||
| 290 | color: headerMuted, | ||
| 291 | }} | ||
| 292 | > | ||
| 293 | {selectedSection?.title ?? "Review changes"} | ||
| 294 | </NativeText> | ||
| 295 | {pendingReviewCommentCount > 0 ? ( | ||
| 296 | <NativeText | ||
| 297 | style={{ | ||
| 298 | fontFamily: "DMSans_700Bold", | ||
| 299 | fontSize: 12, | ||
| 300 | fontWeight: "700", | ||
| 301 | color: "#b45309", | ||
| 302 | }} | ||
| 303 | > | ||
| 304 | {pendingReviewCommentCount} pending | ||
| 305 | </NativeText> | ||
| 306 | ) : null} | ||
| 307 | </View> | ||
| 308 | )} | ||
| 309 | </View> | ||
| 310 | </View> | ||
| 311 | ), | ||
| 312 | }} | ||
| 313 | /> | ||
| 314 | |||
| 315 | <Stack.Toolbar placement="right"> | ||
| 316 | <Stack.Toolbar.Menu icon="ellipsis.circle" title="Select diff" separateBackground> | ||
| 317 | {reviewSections.map((section) => ( | ||
| 318 | <Stack.Toolbar.MenuAction | ||
| 319 | key={section.id} | ||
| 320 | icon={section.id === selectedSection?.id ? "checkmark" : "circle"} | ||
| 321 | onPress={() => selectSection(section.id)} | ||
| 322 | subtitle={section.subtitle ?? undefined} | ||
| 323 | > | ||
| 324 | <Stack.Toolbar.Label>{section.title}</Stack.Toolbar.Label> | ||
| 325 | </Stack.Toolbar.MenuAction> | ||
| 326 | ))} | ||
| 327 | <Stack.Toolbar.MenuAction | ||
| 328 | icon="arrow.clockwise" | ||
| 329 | disabled={ | ||
| 330 | loadingGitDiffs || | ||
| 331 | (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true) | ||
| 332 | } | ||
| 333 | onPress={() => void refreshSelectedSection()} | ||
| 334 | subtitle="Reload current diff" | ||
| 335 | > | ||
| 336 | <Stack.Toolbar.Label>Refresh</Stack.Toolbar.Label> | ||
| 337 | </Stack.Toolbar.MenuAction> | ||
| 338 | </Stack.Toolbar.Menu> | ||
| 339 | </Stack.Toolbar> | ||
| 340 | |||
| 341 | <View className="flex-1 bg-sheet"> | ||
| 342 | {selectedSection && parsedDiff.kind === "files" ? ( | ||
| 343 | <View | ||
| 344 | className="flex-1" | ||
| 345 | style={{ | ||
| 346 | backgroundColor: nativeBridge.theme.background, | ||
| 347 | paddingTop: topContentInset + REVIEW_HEADER_SPACING, | ||
| 348 | }} | ||
| 349 | > | ||
| 350 | {listHeader} | ||
| 351 | <View className="flex-1" collapsable={false}> | ||
| 352 | <NativeReviewDiffView | ||
| 353 | key={`${reviewCache.threadKey}:${selectedSection.id}`} | ||
| 354 | collapsable={false} | ||
| 355 | testID="review-native-diff-view" | ||
| 356 | style={StyleSheet.absoluteFill} | ||
| 357 | appearanceScheme={selectedTheme} | ||
| 358 | collapsedFileIdsJson={nativeBridge.collapsedFileIdsJson} | ||
| 359 | collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} | ||
| 360 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} | ||
| 361 | rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT} | ||
| 362 | rowsJson={nativeBridge.rowsJson} | ||
| 363 | selectedRowIdsJson={nativeBridge.selectedRowIdsJson} | ||
| 364 | styleJson={nativeBridge.styleJson} | ||
| 365 | themeJson={nativeBridge.themeJson} | ||
| 366 | tokensPatchJson={nativeBridge.tokensPatchJson} | ||
| 367 | tokensResetKey={nativeBridge.tokensResetKey} | ||
| 368 | viewedFileIdsJson={nativeBridge.viewedFileIdsJson} | ||
| 369 | onDebug={nativeBridge.onDebug} | ||
| 370 | onPressLine={commentSelection.onPressLine} | ||
| 371 | onToggleComment={nativeBridge.onToggleComment} | ||
| 372 | onToggleFile={handleNativeToggleFile} | ||
| 373 | onToggleViewedFile={handleNativeToggleViewedFile} | ||
| 374 | /> | ||
| 375 | </View> | ||
| 376 | </View> | ||
| 377 | ) : ( | ||
| 378 | <ScrollView | ||
| 379 | contentInsetAdjustmentBehavior="never" | ||
| 380 | contentInset={{ top: topContentInset, bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 381 | contentOffset={{ x: 0, y: -topContentInset }} | ||
| 382 | scrollIndicatorInsets={{ | ||
| 383 | top: topContentInset, | ||
| 384 | bottom: Math.max(insets.bottom, 18) + 18, | ||
| 385 | }} | ||
| 386 | showsVerticalScrollIndicator={false} | ||
| 387 | style={{ flex: 1 }} | ||
| 388 | > | ||
| 389 | {listHeader} | ||
| 390 | {!selectedSection ? ( | ||
| 391 | <View className="border-b border-border bg-card px-4 py-5"> | ||
| 392 | <Text className="text-[14px] font-t3-bold text-foreground">No review diffs</Text> | ||
| 393 | <Text className="text-[12px] leading-[18px] text-foreground-muted"> | ||
| 394 | This thread has no ready turn diffs and the worktree diff is empty. | ||
| 395 | </Text> | ||
| 396 | </View> | ||
| 397 | ) : selectedSection.isLoading && selectedSection.diff === null ? ( | ||
| 398 | <View className="items-center gap-3 border-b border-border bg-card px-4 py-6"> | ||
| 399 | <ActivityIndicator size="small" /> | ||
| 400 | <Text className="text-[12px] text-foreground-muted">Loading diff…</Text> | ||
| 401 | </View> | ||
| 402 | ) : parsedDiff.kind === "empty" ? ( | ||
| 403 | <View className="border-b border-border bg-card px-4 py-5"> | ||
| 404 | <Text className="text-[14px] font-t3-bold text-foreground">No changes</Text> | ||
| 405 | <Text className="text-[12px] leading-[18px] text-foreground-muted"> | ||
| 406 | {selectedSection.subtitle ?? "This diff is empty."} | ||
| 407 | </Text> | ||
| 408 | </View> | ||
| 409 | ) : parsedDiff.kind === "raw" ? ( | ||
| 410 | <View className="gap-3 border-b border-border bg-card px-4 py-4"> | ||
| 411 | <Text className="text-[12px] leading-[18px] text-foreground-muted"> | ||
| 412 | {parsedDiff.reason} | ||
| 413 | </Text> | ||
| 414 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> | ||
| 415 | <Text selectable className="font-mono text-[12px] leading-[19px] text-foreground"> | ||
| 416 | {parsedDiff.text} | ||
| 417 | </Text> | ||
| 418 | </ScrollView> | ||
| 419 | </View> | ||
| 420 | ) : null} | ||
| 421 | </ScrollView> | ||
| 422 | )} | ||
| 423 | <ReviewSelectionActionBar | ||
| 424 | bottomInset={insets.bottom} | ||
| 425 | title={commentSelection.selectionAction?.title ?? null} | ||
| 426 | onOpenComment={commentSelection.selectionAction?.onOpenComment ?? null} | ||
| 427 | onClear={commentSelection.clearSelection} | ||
| 428 | /> | ||
| 429 | </View> | ||
| 430 | </> | ||
| 431 | ); | ||
| 432 | } | ||
apps/mobile/src/features/review/diffParser.ts created+158| ... | @@ -0,0 +1,158 @@ | ||
| 1 | export type ParsedDiffLineType = "context" | "add" | "delete" | "meta" | "hunk"; | ||
| 2 | |||
| 3 | export interface ParsedDiffLine { | ||
| 4 | readonly id: string; | ||
| 5 | readonly type: ParsedDiffLineType; | ||
| 6 | readonly oldLine: number | null; | ||
| 7 | readonly newLine: number | null; | ||
| 8 | readonly content: string; | ||
| 9 | } | ||
| 10 | |||
| 11 | export interface ParsedDiffFile { | ||
| 12 | readonly id: string; | ||
| 13 | readonly oldPath: string | null; | ||
| 14 | readonly newPath: string | null; | ||
| 15 | readonly lines: ReadonlyArray<ParsedDiffLine>; | ||
| 16 | } | ||
| 17 | |||
| 18 | function parseHunkStart( | ||
| 19 | line: string, | ||
| 20 | ): { readonly oldLine: number; readonly newLine: number } | null { | ||
| 21 | const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); | ||
| 22 | if (!match) { | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | |||
| 26 | return { | ||
| 27 | oldLine: Number.parseInt(match[1] ?? "0", 10), | ||
| 28 | newLine: Number.parseInt(match[2] ?? "0", 10), | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | |||
| 32 | function parseDiffPath(line: string, prefix: "--- " | "+++ "): string | null { | ||
| 33 | if (!line.startsWith(prefix)) { | ||
| 34 | return null; | ||
| 35 | } | ||
| 36 | const raw = line.slice(prefix.length).trim(); | ||
| 37 | if (raw === "/dev/null") { | ||
| 38 | return null; | ||
| 39 | } | ||
| 40 | return raw.replace(/^[ab]\//, ""); | ||
| 41 | } | ||
| 42 | |||
| 43 | export function parseUnifiedDiff(diff: string): ReadonlyArray<ParsedDiffFile> { | ||
| 44 | const files: ParsedDiffFile[] = []; | ||
| 45 | let current: { | ||
| 46 | oldPath: string | null; | ||
| 47 | newPath: string | null; | ||
| 48 | lines: ParsedDiffLine[]; | ||
| 49 | } | null = null; | ||
| 50 | let oldLine: number | null = null; | ||
| 51 | let newLine: number | null = null; | ||
| 52 | |||
| 53 | const pushCurrent = () => { | ||
| 54 | if (!current) { | ||
| 55 | return; | ||
| 56 | } | ||
| 57 | files.push({ | ||
| 58 | id: `${current.oldPath ?? "null"}:${current.newPath ?? "null"}:${files.length}`, | ||
| 59 | oldPath: current.oldPath, | ||
| 60 | newPath: current.newPath, | ||
| 61 | lines: current.lines, | ||
| 62 | }); | ||
| 63 | }; | ||
| 64 | |||
| 65 | for (const rawLine of diff.replace(/\r\n/g, "\n").split("\n")) { | ||
| 66 | if (rawLine.startsWith("diff --git ")) { | ||
| 67 | pushCurrent(); | ||
| 68 | const match = rawLine.match(/^diff --git a\/(.+) b\/(.+)$/); | ||
| 69 | current = { | ||
| 70 | oldPath: match?.[1] ?? null, | ||
| 71 | newPath: match?.[2] ?? null, | ||
| 72 | lines: [], | ||
| 73 | }; | ||
| 74 | oldLine = null; | ||
| 75 | newLine = null; | ||
| 76 | continue; | ||
| 77 | } | ||
| 78 | |||
| 79 | if (!current) { | ||
| 80 | if (rawLine.trim().length === 0) { | ||
| 81 | continue; | ||
| 82 | } | ||
| 83 | current = { oldPath: null, newPath: null, lines: [] }; | ||
| 84 | } | ||
| 85 | |||
| 86 | const oldPath = parseDiffPath(rawLine, "--- "); | ||
| 87 | if (oldPath !== null || rawLine === "--- /dev/null") { | ||
| 88 | current.oldPath = oldPath; | ||
| 89 | continue; | ||
| 90 | } | ||
| 91 | |||
| 92 | const newPath = parseDiffPath(rawLine, "+++ "); | ||
| 93 | if (newPath !== null || rawLine === "+++ /dev/null") { | ||
| 94 | current.newPath = newPath; | ||
| 95 | continue; | ||
| 96 | } | ||
| 97 | |||
| 98 | const hunk = parseHunkStart(rawLine); | ||
| 99 | if (hunk) { | ||
| 100 | oldLine = hunk.oldLine; | ||
| 101 | newLine = hunk.newLine; | ||
| 102 | current.lines.push({ | ||
| 103 | id: `${current.lines.length}:hunk`, | ||
| 104 | type: "hunk", | ||
| 105 | oldLine: null, | ||
| 106 | newLine: null, | ||
| 107 | content: rawLine, | ||
| 108 | }); | ||
| 109 | continue; | ||
| 110 | } | ||
| 111 | |||
| 112 | if (oldLine === null || newLine === null) { | ||
| 113 | current.lines.push({ | ||
| 114 | id: `${current.lines.length}:meta`, | ||
| 115 | type: "meta", | ||
| 116 | oldLine: null, | ||
| 117 | newLine: null, | ||
| 118 | content: rawLine, | ||
| 119 | }); | ||
| 120 | continue; | ||
| 121 | } | ||
| 122 | |||
| 123 | const marker = rawLine[0]; | ||
| 124 | const content = rawLine.length > 0 ? rawLine.slice(1) : ""; | ||
| 125 | if (marker === "+") { | ||
| 126 | current.lines.push({ | ||
| 127 | id: `${current.lines.length}:add:${newLine}`, | ||
| 128 | type: "add", | ||
| 129 | oldLine: null, | ||
| 130 | newLine, | ||
| 131 | content, | ||
| 132 | }); | ||
| 133 | newLine += 1; | ||
| 134 | } else if (marker === "-") { | ||
| 135 | current.lines.push({ | ||
| 136 | id: `${current.lines.length}:delete:${oldLine}`, | ||
| 137 | type: "delete", | ||
| 138 | oldLine, | ||
| 139 | newLine: null, | ||
| 140 | content, | ||
| 141 | }); | ||
| 142 | oldLine += 1; | ||
| 143 | } else { | ||
| 144 | current.lines.push({ | ||
| 145 | id: `${current.lines.length}:context:${oldLine}:${newLine}`, | ||
| 146 | type: "context", | ||
| 147 | oldLine, | ||
| 148 | newLine, | ||
| 149 | content: marker === " " ? content : rawLine, | ||
| 150 | }); | ||
| 151 | oldLine += 1; | ||
| 152 | newLine += 1; | ||
| 153 | } | ||
| 154 | } | ||
| 155 | |||
| 156 | pushCurrent(); | ||
| 157 | return files.filter((file) => file.lines.length > 0 || file.oldPath || file.newPath); | ||
| 158 | } | ||
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts created+431| ... | @@ -0,0 +1,431 @@ | ||
| 1 | import type { NativeReviewDiffRow, NativeReviewDiffTheme } from "../diffs/nativeReviewDiffSurface"; | ||
| 2 | import type { | ||
| 3 | NativeReviewDiffFile, | ||
| 4 | NativeReviewDiffLanguage, | ||
| 5 | } from "../diffs/nativeReviewDiffTypes"; | ||
| 6 | import * as Arr from "effect/Array"; | ||
| 7 | import { pipe } from "effect/Function"; | ||
| 8 | import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; | ||
| 9 | import { computeWordAltDiffRanges } from "./reviewWordDiffs"; | ||
| 10 | import { | ||
| 11 | getReviewFilePreviewState, | ||
| 12 | type ReviewParsedDiff, | ||
| 13 | type ReviewRenderableFile, | ||
| 14 | type ReviewRenderableLineRow, | ||
| 15 | } from "./reviewModel"; | ||
| 16 | import type { ReviewInlineComment } from "./reviewCommentSelection"; | ||
| 17 | |||
| 18 | const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4; | ||
| 19 | const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; | ||
| 20 | |||
| 21 | export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = 20; | ||
| 22 | export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; | ||
| 23 | |||
| 24 | export const NATIVE_REVIEW_DIFF_STYLE = { | ||
| 25 | rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT, | ||
| 26 | contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, | ||
| 27 | changeBarWidth: 4, | ||
| 28 | gutterWidth: 46, | ||
| 29 | codePadding: 7, | ||
| 30 | textVerticalInset: 2, | ||
| 31 | fileHeaderHeight: 56, | ||
| 32 | fileHeaderHorizontalMargin: 8, | ||
| 33 | fileHeaderVerticalMargin: 6, | ||
| 34 | fileHeaderCornerRadius: 10, | ||
| 35 | fileHeaderHorizontalPadding: 10, | ||
| 36 | fileHeaderPathRightPadding: 118, | ||
| 37 | fileHeaderCountColumnWidth: 38, | ||
| 38 | fileHeaderCountGap: 5, | ||
| 39 | codeFontSize: 11, | ||
| 40 | codeFontWeight: "regular", | ||
| 41 | lineNumberFontSize: 10, | ||
| 42 | lineNumberFontWeight: "regular", | ||
| 43 | hunkFontSize: 11, | ||
| 44 | hunkFontWeight: "medium", | ||
| 45 | fileHeaderFontSize: 11, | ||
| 46 | fileHeaderFontWeight: "semibold", | ||
| 47 | fileHeaderMetaFontSize: 10, | ||
| 48 | fileHeaderMetaFontWeight: "semibold", | ||
| 49 | fileHeaderSubtextFontSize: 11, | ||
| 50 | fileHeaderSubtextFontWeight: "medium", | ||
| 51 | fileHeaderStatusFontSize: 9, | ||
| 52 | fileHeaderStatusFontWeight: "bold", | ||
| 53 | emptyStateFontSize: 12, | ||
| 54 | emptyStateFontWeight: "medium", | ||
| 55 | } as const; | ||
| 56 | |||
| 57 | export interface NativeReviewDiffData { | ||
| 58 | readonly rows: ReadonlyArray<NativeReviewDiffRow>; | ||
| 59 | readonly files: ReadonlyArray<NativeReviewDiffFile>; | ||
| 60 | readonly commentTargetsByRowId: ReadonlyMap<string, NativeReviewDiffCommentTarget>; | ||
| 61 | readonly rowIdByCommentLineId: ReadonlyMap<string, string>; | ||
| 62 | readonly additions: number; | ||
| 63 | readonly deletions: number; | ||
| 64 | } | ||
| 65 | |||
| 66 | export interface NativeReviewDiffCommentTarget { | ||
| 67 | readonly filePath: string; | ||
| 68 | readonly lines: ReadonlyArray<ReviewRenderableLineRow>; | ||
| 69 | readonly lineIndex: number; | ||
| 70 | } | ||
| 71 | |||
| 72 | export interface BuildNativeReviewDiffDataInput { | ||
| 73 | readonly parsedDiff: ReviewParsedDiff; | ||
| 74 | readonly comments?: ReadonlyArray<ReviewInlineComment>; | ||
| 75 | } | ||
| 76 | |||
| 77 | export function createNativeReviewDiffTheme( | ||
| 78 | scheme: TerminalAppearanceScheme, | ||
| 79 | ): NativeReviewDiffTheme { | ||
| 80 | const terminalTheme = getPierreTerminalTheme(scheme); | ||
| 81 | const [, terminalRed, , , terminalBlue] = terminalTheme.palette; | ||
| 82 | |||
| 83 | if (scheme === "dark") { | ||
| 84 | return { | ||
| 85 | background: terminalTheme.background, | ||
| 86 | text: terminalTheme.foreground, | ||
| 87 | mutedText: terminalTheme.mutedForeground, | ||
| 88 | headerBackground: terminalTheme.background, | ||
| 89 | border: terminalTheme.border, | ||
| 90 | hunkBackground: "#071f28", | ||
| 91 | hunkText: terminalBlue ?? "#009fff", | ||
| 92 | addBackground: "#0d2f28", | ||
| 93 | deleteBackground: "#391415", | ||
| 94 | addBar: "#00cab1", | ||
| 95 | deleteBar: terminalRed ?? "#ff2e3f", | ||
| 96 | addText: "#5ECC71", | ||
| 97 | deleteText: "#FF6762", | ||
| 98 | }; | ||
| 99 | } | ||
| 100 | |||
| 101 | return { | ||
| 102 | background: "#ffffff", | ||
| 103 | text: "#070707", | ||
| 104 | mutedText: terminalTheme.mutedForeground, | ||
| 105 | headerBackground: "#ffffff", | ||
| 106 | border: terminalTheme.border, | ||
| 107 | hunkBackground: "#e0f2ff", | ||
| 108 | hunkText: terminalBlue ?? "#009fff", | ||
| 109 | addBackground: "#e5f8f5", | ||
| 110 | deleteBackground: "#ffe6e7", | ||
| 111 | addBar: "#00cab1", | ||
| 112 | deleteBar: terminalRed ?? "#ff2e3f", | ||
| 113 | addText: "#199F43", | ||
| 114 | deleteText: "#D52C36", | ||
| 115 | }; | ||
| 116 | } | ||
| 117 | |||
| 118 | function mapChangeType(file: ReviewRenderableFile): NativeReviewDiffRow["changeType"] { | ||
| 119 | switch (file.changeType) { | ||
| 120 | case "change": | ||
| 121 | return "modified"; | ||
| 122 | case "new": | ||
| 123 | case "deleted": | ||
| 124 | case "rename-pure": | ||
| 125 | case "rename-changed": | ||
| 126 | return file.changeType; | ||
| 127 | default: | ||
| 128 | return "modified"; | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | function getLanguageForPath( | ||
| 133 | filePath: string, | ||
| 134 | languageHint: string | null, | ||
| 135 | ): NativeReviewDiffLanguage { | ||
| 136 | const hinted = languageHint?.toLowerCase(); | ||
| 137 | if (hinted === "typescript" || hinted === "tsx" || hinted === "javascript" || hinted === "jsx") { | ||
| 138 | return hinted; | ||
| 139 | } | ||
| 140 | if (hinted === "json" || hinted === "yaml" || hinted === "bash" || hinted === "diff") { | ||
| 141 | return hinted; | ||
| 142 | } | ||
| 143 | |||
| 144 | const normalizedPath = filePath.toLowerCase(); | ||
| 145 | if (normalizedPath.endsWith(".tsx")) return "tsx"; | ||
| 146 | if (normalizedPath.endsWith(".ts")) return "typescript"; | ||
| 147 | if (normalizedPath.endsWith(".jsx")) return "jsx"; | ||
| 148 | if (normalizedPath.endsWith(".js") || normalizedPath.endsWith(".cjs")) return "javascript"; | ||
| 149 | if (normalizedPath.endsWith(".json") || normalizedPath.endsWith(".jsonc")) return "json"; | ||
| 150 | if (normalizedPath.endsWith(".yml") || normalizedPath.endsWith(".yaml")) return "yaml"; | ||
| 151 | if ( | ||
| 152 | normalizedPath.endsWith(".sh") || | ||
| 153 | normalizedPath.includes("/bin/") || | ||
| 154 | normalizedPath.includes("shell") | ||
| 155 | ) { | ||
| 156 | return "bash"; | ||
| 157 | } | ||
| 158 | return "diff"; | ||
| 159 | } | ||
| 160 | |||
| 161 | function createNoticeRow(fileId: string, suffix: string, text: string): NativeReviewDiffRow { | ||
| 162 | return { | ||
| 163 | kind: "notice", | ||
| 164 | id: `${fileId}:notice:${suffix}`, | ||
| 165 | fileId, | ||
| 166 | text, | ||
| 167 | }; | ||
| 168 | } | ||
| 169 | |||
| 170 | function noticeRowsForFile(file: ReviewRenderableFile): ReadonlyArray<NativeReviewDiffRow> { | ||
| 171 | if (file.rows.length > 0) { | ||
| 172 | return []; | ||
| 173 | } | ||
| 174 | |||
| 175 | const previewState = getReviewFilePreviewState(file); | ||
| 176 | if (previewState.kind === "suppressed" && previewState.reason === "non-text") { | ||
| 177 | return [ | ||
| 178 | createNoticeRow(file.id, "non-text", "Unsupported format. Diff contents are not available."), | ||
| 179 | ]; | ||
| 180 | } | ||
| 181 | |||
| 182 | if (file.changeType === "rename-pure") { | ||
| 183 | return [createNoticeRow(file.id, "rename", "This file was renamed without modifications.")]; | ||
| 184 | } | ||
| 185 | |||
| 186 | return []; | ||
| 187 | } | ||
| 188 | |||
| 189 | function trimWordDiffRanges( | ||
| 190 | content: string, | ||
| 191 | ranges: NonNullable<NativeReviewDiffRow["wordDiffRanges"]>, | ||
| 192 | ): NonNullable<NativeReviewDiffRow["wordDiffRanges"]> { | ||
| 193 | return pipe( | ||
| 194 | ranges, | ||
| 195 | Arr.flatMap((range) => { | ||
| 196 | let start = Math.max(0, range.start); | ||
| 197 | let end = Math.min(content.length, range.end); | ||
| 198 | |||
| 199 | while (start < end && /\s/.test(content[start] ?? "")) { | ||
| 200 | start += 1; | ||
| 201 | } | ||
| 202 | while (end > start && /\s/.test(content[end - 1] ?? "")) { | ||
| 203 | end -= 1; | ||
| 204 | } | ||
| 205 | |||
| 206 | return end > start ? [{ start, end }] : []; | ||
| 207 | }), | ||
| 208 | ); | ||
| 209 | } | ||
| 210 | |||
| 211 | function nonWhitespaceLength(value: string) { | ||
| 212 | return value.replace(/\s/g, "").length; | ||
| 213 | } | ||
| 214 | |||
| 215 | function shouldUseWordDiffRanges( | ||
| 216 | content: string, | ||
| 217 | ranges: NonNullable<NativeReviewDiffRow["wordDiffRanges"]>, | ||
| 218 | ) { | ||
| 219 | if (ranges.length === 0 || ranges.length > NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT) { | ||
| 220 | return false; | ||
| 221 | } | ||
| 222 | |||
| 223 | const meaningfulLength = nonWhitespaceLength(content); | ||
| 224 | if (meaningfulLength === 0) { | ||
| 225 | return false; | ||
| 226 | } | ||
| 227 | |||
| 228 | const highlightedLength = ranges.reduce( | ||
| 229 | (total, range) => total + nonWhitespaceLength(content.slice(range.start, range.end)), | ||
| 230 | 0, | ||
| 231 | ); | ||
| 232 | return highlightedLength / meaningfulLength <= NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE; | ||
| 233 | } | ||
| 234 | |||
| 235 | function addNativeWordDiffRanges( | ||
| 236 | rows: ReadonlyArray<NativeReviewDiffRow>, | ||
| 237 | ): ReadonlyArray<NativeReviewDiffRow> { | ||
| 238 | const nextRows = [...rows]; | ||
| 239 | let index = 0; | ||
| 240 | |||
| 241 | while (index < nextRows.length) { | ||
| 242 | const deletedRowIndexes: number[] = []; | ||
| 243 | const addedRowIndexes: number[] = []; | ||
| 244 | const fileId = nextRows[index]?.fileId; | ||
| 245 | |||
| 246 | while ( | ||
| 247 | nextRows[index]?.kind === "line" && | ||
| 248 | nextRows[index]?.change === "delete" && | ||
| 249 | nextRows[index]?.fileId === fileId | ||
| 250 | ) { | ||
| 251 | deletedRowIndexes.push(index); | ||
| 252 | index += 1; | ||
| 253 | } | ||
| 254 | |||
| 255 | while ( | ||
| 256 | nextRows[index]?.kind === "line" && | ||
| 257 | nextRows[index]?.change === "add" && | ||
| 258 | nextRows[index]?.fileId === fileId | ||
| 259 | ) { | ||
| 260 | addedRowIndexes.push(index); | ||
| 261 | index += 1; | ||
| 262 | } | ||
| 263 | |||
| 264 | const pairedCount = Math.min(deletedRowIndexes.length, addedRowIndexes.length); | ||
| 265 | for (let pairIndex = 0; pairIndex < pairedCount; pairIndex += 1) { | ||
| 266 | const deletedRowIndex = deletedRowIndexes[pairIndex]; | ||
| 267 | const addedRowIndex = addedRowIndexes[pairIndex]; | ||
| 268 | const deletedRow = nextRows[deletedRowIndex]; | ||
| 269 | const addedRow = nextRows[addedRowIndex]; | ||
| 270 | if (!deletedRow?.content || !addedRow?.content) { | ||
| 271 | continue; | ||
| 272 | } | ||
| 273 | |||
| 274 | const ranges = computeWordAltDiffRanges({ | ||
| 275 | deletionLine: deletedRow.content, | ||
| 276 | additionLine: addedRow.content, | ||
| 277 | }); | ||
| 278 | const deletionRanges = trimWordDiffRanges(deletedRow.content, ranges.deletion); | ||
| 279 | const additionRanges = trimWordDiffRanges(addedRow.content, ranges.addition); | ||
| 280 | |||
| 281 | if (shouldUseWordDiffRanges(deletedRow.content, deletionRanges)) { | ||
| 282 | nextRows[deletedRowIndex] = { ...deletedRow, wordDiffRanges: deletionRanges }; | ||
| 283 | } | ||
| 284 | if (shouldUseWordDiffRanges(addedRow.content, additionRanges)) { | ||
| 285 | nextRows[addedRowIndex] = { ...addedRow, wordDiffRanges: additionRanges }; | ||
| 286 | } | ||
| 287 | } | ||
| 288 | |||
| 289 | if (deletedRowIndexes.length === 0 && addedRowIndexes.length === 0) { | ||
| 290 | index += 1; | ||
| 291 | } | ||
| 292 | } | ||
| 293 | |||
| 294 | return nextRows; | ||
| 295 | } | ||
| 296 | |||
| 297 | function mapLineRow( | ||
| 298 | file: ReviewRenderableFile, | ||
| 299 | row: ReviewRenderableLineRow, | ||
| 300 | rowIndex: number, | ||
| 301 | ): NativeReviewDiffRow { | ||
| 302 | return { | ||
| 303 | kind: "line", | ||
| 304 | id: `${file.id}:line:${rowIndex}:${row.id}`, | ||
| 305 | fileId: file.id, | ||
| 306 | content: row.content, | ||
| 307 | change: row.change, | ||
| 308 | oldLineNumber: row.oldLineNumber, | ||
| 309 | newLineNumber: row.newLineNumber, | ||
| 310 | }; | ||
| 311 | } | ||
| 312 | |||
| 313 | function mapFileRows( | ||
| 314 | file: ReviewRenderableFile, | ||
| 315 | comments: ReadonlyArray<ReviewInlineComment>, | ||
| 316 | commentTargetsByRowId: Map<string, NativeReviewDiffCommentTarget>, | ||
| 317 | rowIdByCommentLineId: Map<string, string>, | ||
| 318 | ): ReadonlyArray<NativeReviewDiffRow> { | ||
| 319 | const rows: NativeReviewDiffRow[] = [ | ||
| 320 | { | ||
| 321 | kind: "file", | ||
| 322 | id: `${file.id}:header`, | ||
| 323 | fileId: file.id, | ||
| 324 | filePath: file.path, | ||
| 325 | previousPath: file.previousPath, | ||
| 326 | changeType: mapChangeType(file), | ||
| 327 | additions: file.additions, | ||
| 328 | deletions: file.deletions, | ||
| 329 | }, | ||
| 330 | ]; | ||
| 331 | |||
| 332 | const lineRows = file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line"); | ||
| 333 | const commentsByEndIndex = new Map<number, ReviewInlineComment[]>(); | ||
| 334 | comments.forEach((comment) => { | ||
| 335 | if (comment.filePath !== file.path) { | ||
| 336 | return; | ||
| 337 | } | ||
| 338 | const endIndex = Math.min(comment.endIndex, lineRows.length - 1); | ||
| 339 | if (endIndex < 0) { | ||
| 340 | return; | ||
| 341 | } | ||
| 342 | const existing = commentsByEndIndex.get(endIndex); | ||
| 343 | if (existing) { | ||
| 344 | existing.push(comment); | ||
| 345 | return; | ||
| 346 | } | ||
| 347 | commentsByEndIndex.set(endIndex, [comment]); | ||
| 348 | }); | ||
| 349 | let lineIndex = 0; | ||
| 350 | file.rows.forEach((row, rowIndex) => { | ||
| 351 | if (row.kind === "hunk") { | ||
| 352 | rows.push({ | ||
| 353 | kind: "hunk", | ||
| 354 | id: `${file.id}:hunk:${rowIndex}:${row.id}`, | ||
| 355 | fileId: file.id, | ||
| 356 | text: row.context ? `${row.header} ${row.context}` : row.header, | ||
| 357 | }); | ||
| 358 | return; | ||
| 359 | } | ||
| 360 | |||
| 361 | const nativeRow = mapLineRow(file, row, rowIndex); | ||
| 362 | rows.push(nativeRow); | ||
| 363 | rowIdByCommentLineId.set(row.id, nativeRow.id); | ||
| 364 | commentTargetsByRowId.set(nativeRow.id, { | ||
| 365 | filePath: file.path, | ||
| 366 | lines: lineRows, | ||
| 367 | lineIndex, | ||
| 368 | }); | ||
| 369 | const commentsForLine = commentsByEndIndex.get(lineIndex) ?? []; | ||
| 370 | for (const comment of commentsForLine) { | ||
| 371 | rows.push({ | ||
| 372 | kind: "comment", | ||
| 373 | id: comment.id, | ||
| 374 | fileId: file.id, | ||
| 375 | filePath: file.path, | ||
| 376 | commentText: comment.text, | ||
| 377 | commentRangeLabel: comment.rangeLabel, | ||
| 378 | commentSectionTitle: comment.sectionTitle, | ||
| 379 | }); | ||
| 380 | } | ||
| 381 | lineIndex += 1; | ||
| 382 | }); | ||
| 383 | |||
| 384 | rows.push(...noticeRowsForFile(file)); | ||
| 385 | return rows; | ||
| 386 | } | ||
| 387 | |||
| 388 | export function buildNativeReviewDiffData( | ||
| 389 | input: BuildNativeReviewDiffDataInput, | ||
| 390 | ): NativeReviewDiffData; | ||
| 391 | export function buildNativeReviewDiffData(parsedDiff: ReviewParsedDiff): NativeReviewDiffData; | ||
| 392 | export function buildNativeReviewDiffData( | ||
| 393 | input: ReviewParsedDiff | BuildNativeReviewDiffDataInput, | ||
| 394 | ): NativeReviewDiffData { | ||
| 395 | const parsedDiff = "parsedDiff" in input ? input.parsedDiff : input; | ||
| 396 | const comments = "parsedDiff" in input ? (input.comments ?? []) : []; | ||
| 397 | if (parsedDiff.kind !== "files") { | ||
| 398 | return { | ||
| 399 | rows: [], | ||
| 400 | files: [], | ||
| 401 | commentTargetsByRowId: new Map(), | ||
| 402 | rowIdByCommentLineId: new Map(), | ||
| 403 | additions: 0, | ||
| 404 | deletions: 0, | ||
| 405 | }; | ||
| 406 | } | ||
| 407 | |||
| 408 | const files = parsedDiff.files.map<NativeReviewDiffFile>((file) => ({ | ||
| 409 | id: file.id, | ||
| 410 | path: file.path, | ||
| 411 | language: getLanguageForPath(file.path, file.languageHint), | ||
| 412 | additions: file.additions, | ||
| 413 | deletions: file.deletions, | ||
| 414 | })); | ||
| 415 | const commentTargetsByRowId = new Map<string, NativeReviewDiffCommentTarget>(); | ||
| 416 | const rowIdByCommentLineId = new Map<string, string>(); | ||
| 417 | const rows = addNativeWordDiffRanges( | ||
| 418 | Arr.flatMap(parsedDiff.files, (file) => | ||
| 419 | mapFileRows(file, comments, commentTargetsByRowId, rowIdByCommentLineId), | ||
| 420 | ), | ||
| 421 | ); | ||
| 422 | |||
| 423 | return { | ||
| 424 | rows, | ||
| 425 | files, | ||
| 426 | commentTargetsByRowId, | ||
| 427 | rowIdByCommentLineId, | ||
| 428 | additions: parsedDiff.additions, | ||
| 429 | deletions: parsedDiff.deletions, | ||
| 430 | }; | ||
| 431 | } | ||
apps/mobile/src/features/review/reviewCommentSelection.test.ts created+81| ... | @@ -0,0 +1,81 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | countReviewCommentContexts, | ||
| 5 | formatReviewCommentContext, | ||
| 6 | parseReviewCommentMessageSegments, | ||
| 7 | parseReviewInlineComments, | ||
| 8 | type ReviewCommentTarget, | ||
| 9 | } from "./reviewCommentSelection"; | ||
| 10 | |||
| 11 | function makeTarget(): ReviewCommentTarget { | ||
| 12 | return { | ||
| 13 | sectionId: "section-1", | ||
| 14 | sectionTitle: "Working tree", | ||
| 15 | filePath: "apps/demo/src/main.ts", | ||
| 16 | startIndex: 0, | ||
| 17 | endIndex: 1, | ||
| 18 | lines: [ | ||
| 19 | { | ||
| 20 | kind: "line", | ||
| 21 | id: "line-1", | ||
| 22 | change: "delete", | ||
| 23 | oldLineNumber: 7, | ||
| 24 | newLineNumber: null, | ||
| 25 | content: "const retryLimit = 2;", | ||
| 26 | additionTokenIndex: null, | ||
| 27 | deletionTokenIndex: 0, | ||
| 28 | comparison: null, | ||
| 29 | }, | ||
| 30 | { | ||
| 31 | kind: "line", | ||
| 32 | id: "line-2", | ||
| 33 | change: "add", | ||
| 34 | oldLineNumber: null, | ||
| 35 | newLineNumber: 7, | ||
| 36 | content: "const retryLimit = 4;", | ||
| 37 | additionTokenIndex: 0, | ||
| 38 | deletionTokenIndex: null, | ||
| 39 | comparison: null, | ||
| 40 | }, | ||
| 41 | ], | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | |||
| 45 | describe("review comment serialization", () => { | ||
| 46 | it("preserves enough metadata for inline diff rendering", () => { | ||
| 47 | const serialized = formatReviewCommentContext(makeTarget(), "Please keep this configurable."); | ||
| 48 | |||
| 49 | expect(countReviewCommentContexts(serialized)).toBe(1); | ||
| 50 | expect(parseReviewInlineComments(serialized)).toEqual([ | ||
| 51 | expect.objectContaining({ | ||
| 52 | sectionId: "section-1", | ||
| 53 | sectionTitle: "Working tree", | ||
| 54 | filePath: "apps/demo/src/main.ts", | ||
| 55 | startIndex: 0, | ||
| 56 | endIndex: 1, | ||
| 57 | text: "Please keep this configurable.", | ||
| 58 | diff: expect.stringContaining("-const retryLimit = 2;"), | ||
| 59 | }), | ||
| 60 | ]); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("splits chat text into review comment segments", () => { | ||
| 64 | const serialized = `Before\n${formatReviewCommentContext(makeTarget(), "Please keep this configurable.")}\nAfter`; | ||
| 65 | const segments = parseReviewCommentMessageSegments(serialized); | ||
| 66 | |||
| 67 | expect(segments).toHaveLength(3); | ||
| 68 | expect(segments[0]).toEqual(expect.objectContaining({ kind: "text", text: "Before\n" })); | ||
| 69 | expect(segments[1]).toEqual( | ||
| 70 | expect.objectContaining({ | ||
| 71 | kind: "review-comment", | ||
| 72 | comment: expect.objectContaining({ | ||
| 73 | filePath: "apps/demo/src/main.ts", | ||
| 74 | text: "Please keep this configurable.", | ||
| 75 | diff: expect.stringContaining("+const retryLimit = 4;"), | ||
| 76 | }), | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | expect(segments[2]).toEqual(expect.objectContaining({ kind: "text", text: "\nAfter" })); | ||
| 80 | }); | ||
| 81 | }); | ||
apps/mobile/src/features/review/reviewCommentSelection.ts created+317| ... | @@ -0,0 +1,317 @@ | ||
| 1 | import { useSyncExternalStore } from "react"; | ||
| 2 | |||
| 3 | import type { ReviewRenderableLineRow } from "./reviewModel"; | ||
| 4 | |||
| 5 | export interface ReviewCommentTarget { | ||
| 6 | readonly sectionId: string; | ||
| 7 | readonly sectionTitle: string; | ||
| 8 | readonly filePath: string; | ||
| 9 | readonly lines: ReadonlyArray<ReviewRenderableLineRow>; | ||
| 10 | readonly startIndex: number; | ||
| 11 | readonly endIndex: number; | ||
| 12 | } | ||
| 13 | |||
| 14 | export interface ReviewInlineComment { | ||
| 15 | readonly id: string; | ||
| 16 | readonly sectionId: string; | ||
| 17 | readonly sectionTitle: string; | ||
| 18 | readonly filePath: string; | ||
| 19 | readonly startIndex: number; | ||
| 20 | readonly endIndex: number; | ||
| 21 | readonly rangeLabel: string; | ||
| 22 | readonly text: string; | ||
| 23 | readonly diff: string; | ||
| 24 | } | ||
| 25 | |||
| 26 | export type ReviewCommentMessageSegment = | ||
| 27 | | { | ||
| 28 | readonly kind: "text"; | ||
| 29 | readonly id: string; | ||
| 30 | readonly text: string; | ||
| 31 | } | ||
| 32 | | { | ||
| 33 | readonly kind: "review-comment"; | ||
| 34 | readonly comment: ReviewInlineComment; | ||
| 35 | }; | ||
| 36 | |||
| 37 | let currentTarget: ReviewCommentTarget | null = null; | ||
| 38 | const listeners = new Set<() => void>(); | ||
| 39 | const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g; | ||
| 40 | const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g; | ||
| 41 | |||
| 42 | function emitChange() { | ||
| 43 | listeners.forEach((listener) => listener()); | ||
| 44 | } | ||
| 45 | |||
| 46 | export function subscribeReviewCommentTarget(listener: () => void): () => void { | ||
| 47 | listeners.add(listener); | ||
| 48 | return () => { | ||
| 49 | listeners.delete(listener); | ||
| 50 | }; | ||
| 51 | } | ||
| 52 | |||
| 53 | export function getReviewCommentTarget(): ReviewCommentTarget | null { | ||
| 54 | return currentTarget; | ||
| 55 | } | ||
| 56 | |||
| 57 | export function setReviewCommentTarget(target: ReviewCommentTarget | null) { | ||
| 58 | currentTarget = target; | ||
| 59 | emitChange(); | ||
| 60 | } | ||
| 61 | |||
| 62 | export function clearReviewCommentTarget() { | ||
| 63 | currentTarget = null; | ||
| 64 | emitChange(); | ||
| 65 | } | ||
| 66 | |||
| 67 | export function useReviewCommentTarget(): ReviewCommentTarget | null { | ||
| 68 | return useSyncExternalStore( | ||
| 69 | subscribeReviewCommentTarget, | ||
| 70 | getReviewCommentTarget, | ||
| 71 | getReviewCommentTarget, | ||
| 72 | ); | ||
| 73 | } | ||
| 74 | |||
| 75 | export function getSelectedReviewCommentLines( | ||
| 76 | target: ReviewCommentTarget, | ||
| 77 | ): ReadonlyArray<ReviewRenderableLineRow> { | ||
| 78 | return target.lines.slice(target.startIndex, target.endIndex + 1); | ||
| 79 | } | ||
| 80 | |||
| 81 | export function getReviewUnifiedLineNumber(line: ReviewRenderableLineRow): number | null { | ||
| 82 | return line.newLineNumber ?? line.oldLineNumber; | ||
| 83 | } | ||
| 84 | |||
| 85 | export function formatReviewLineLabel(line: ReviewRenderableLineRow): string { | ||
| 86 | if (line.newLineNumber !== null) { | ||
| 87 | return `new line ${line.newLineNumber}`; | ||
| 88 | } | ||
| 89 | if (line.oldLineNumber !== null) { | ||
| 90 | return `old line ${line.oldLineNumber}`; | ||
| 91 | } | ||
| 92 | return "file"; | ||
| 93 | } | ||
| 94 | |||
| 95 | export function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]): string { | ||
| 96 | if (change === "add") return "+"; | ||
| 97 | if (change === "delete") return "-"; | ||
| 98 | return " "; | ||
| 99 | } | ||
| 100 | |||
| 101 | export function buildReviewCommentTarget( | ||
| 102 | target: Pick<ReviewCommentTarget, "sectionId" | "sectionTitle" | "filePath" | "lines">, | ||
| 103 | anchorIndex: number, | ||
| 104 | lineIndex: number, | ||
| 105 | ): ReviewCommentTarget { | ||
| 106 | return { | ||
| 107 | sectionId: target.sectionId, | ||
| 108 | sectionTitle: target.sectionTitle, | ||
| 109 | filePath: target.filePath, | ||
| 110 | lines: target.lines, | ||
| 111 | startIndex: Math.min(anchorIndex, lineIndex), | ||
| 112 | endIndex: Math.max(anchorIndex, lineIndex), | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | |||
| 116 | export function formatReviewSelectedRangeLabel(target: ReviewCommentTarget): string { | ||
| 117 | const lines = getSelectedReviewCommentLines(target); | ||
| 118 | const firstLine = lines[0]!; | ||
| 119 | const lastLine = lines[lines.length - 1]!; | ||
| 120 | const firstNumber = getReviewUnifiedLineNumber(firstLine); | ||
| 121 | const lastNumber = getReviewUnifiedLineNumber(lastLine); | ||
| 122 | |||
| 123 | if (firstNumber === null || lastNumber === null) { | ||
| 124 | return lines.length === 1 ? "line" : `${lines.length} lines`; | ||
| 125 | } | ||
| 126 | |||
| 127 | const firstMarker = getReviewChangeMarker(firstLine.change).trim(); | ||
| 128 | const consistentMarker = | ||
| 129 | lines.every((line) => line.change === firstLine.change) && firstMarker.length > 0 | ||
| 130 | ? getReviewChangeMarker(firstLine.change) | ||
| 131 | : ""; | ||
| 132 | |||
| 133 | if (firstNumber === lastNumber) { | ||
| 134 | return `${consistentMarker}${firstNumber}`; | ||
| 135 | } | ||
| 136 | |||
| 137 | return `${consistentMarker}${firstNumber} to ${consistentMarker}${lastNumber}`; | ||
| 138 | } | ||
| 139 | |||
| 140 | function getDiffHunkRange( | ||
| 141 | selectedLines: ReadonlyArray<ReviewRenderableLineRow>, | ||
| 142 | key: "oldLineNumber" | "newLineNumber", | ||
| 143 | ): { | ||
| 144 | readonly start: number; | ||
| 145 | readonly count: number; | ||
| 146 | } { | ||
| 147 | const numberedLines = selectedLines.filter((line) => line[key] !== null); | ||
| 148 | if (numberedLines.length === 0) { | ||
| 149 | return { start: 0, count: 0 }; | ||
| 150 | } | ||
| 151 | |||
| 152 | return { | ||
| 153 | start: numberedLines[0]![key] ?? 0, | ||
| 154 | count: numberedLines.length, | ||
| 155 | }; | ||
| 156 | } | ||
| 157 | |||
| 158 | function formatReviewSelectedDiff(target: ReviewCommentTarget): string { | ||
| 159 | const selectedLines = getSelectedReviewCommentLines(target); | ||
| 160 | const oldRange = getDiffHunkRange(selectedLines, "oldLineNumber"); | ||
| 161 | const newRange = getDiffHunkRange(selectedLines, "newLineNumber"); | ||
| 162 | const diffBody = selectedLines | ||
| 163 | .map((line) => `${getReviewChangeMarker(line.change)}${line.content}`) | ||
| 164 | .join("\n"); | ||
| 165 | |||
| 166 | return [ | ||
| 167 | `@@ -${oldRange.start},${oldRange.count} +${newRange.start},${newRange.count} @@`, | ||
| 168 | diffBody.length > 0 ? diffBody : " ", | ||
| 169 | ].join("\n"); | ||
| 170 | } | ||
| 171 | |||
| 172 | function escapeReviewCommentAttribute(value: string): string { | ||
| 173 | return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;"); | ||
| 174 | } | ||
| 175 | |||
| 176 | function unescapeReviewCommentAttribute(value: string): string { | ||
| 177 | return value | ||
| 178 | .replace(/&lt;/g, "<") | ||
| 179 | .replace(/&quot;/g, '"') | ||
| 180 | .replace(/&amp;/g, "&"); | ||
| 181 | } | ||
| 182 | |||
| 183 | function readReviewCommentAttributes(rawAttributes: string): Record<string, string> { | ||
| 184 | const attributes: Record<string, string> = {}; | ||
| 185 | for (const match of rawAttributes.matchAll(REVIEW_COMMENT_ATTRIBUTE_PATTERN)) { | ||
| 186 | attributes[match[1]!] = unescapeReviewCommentAttribute(match[2] ?? ""); | ||
| 187 | } | ||
| 188 | return attributes; | ||
| 189 | } | ||
| 190 | |||
| 191 | function readNonNegativeInteger(value: string | undefined): number | null { | ||
| 192 | if (value === undefined || !/^\d+$/.test(value)) { | ||
| 193 | return null; | ||
| 194 | } | ||
| 195 | return Number(value); | ||
| 196 | } | ||
| 197 | |||
| 198 | function extractReviewCommentText(rawBody: string): string { | ||
| 199 | const fenceIndex = rawBody.indexOf("```diff"); | ||
| 200 | const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody; | ||
| 201 | return commentBody.trim(); | ||
| 202 | } | ||
| 203 | |||
| 204 | function extractReviewCommentDiff(rawBody: string): string { | ||
| 205 | const match = rawBody.match(/```diff\s*\n([\s\S]*?)\n```/); | ||
| 206 | return match?.[1]?.trim() ?? ""; | ||
| 207 | } | ||
| 208 | |||
| 209 | function parseReviewInlineComment( | ||
| 210 | rawAttributes: string, | ||
| 211 | rawBody: string, | ||
| 212 | index: number, | ||
| 213 | ): ReviewInlineComment | null { | ||
| 214 | const attributes = readReviewCommentAttributes(rawAttributes); | ||
| 215 | const startIndex = readNonNegativeInteger(attributes.startIndex); | ||
| 216 | const endIndex = readNonNegativeInteger(attributes.endIndex); | ||
| 217 | const filePath = attributes.filePath?.trim(); | ||
| 218 | const sectionId = attributes.sectionId?.trim(); | ||
| 219 | if (!filePath || !sectionId || startIndex === null || endIndex === null) { | ||
| 220 | return null; | ||
| 221 | } | ||
| 222 | |||
| 223 | return { | ||
| 224 | id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`, | ||
| 225 | sectionId, | ||
| 226 | sectionTitle: attributes.sectionTitle?.trim() || "Review", | ||
| 227 | filePath, | ||
| 228 | startIndex: Math.min(startIndex, endIndex), | ||
| 229 | endIndex: Math.max(startIndex, endIndex), | ||
| 230 | rangeLabel: attributes.rangeLabel?.trim() || "line", | ||
| 231 | text: extractReviewCommentText(rawBody), | ||
| 232 | diff: extractReviewCommentDiff(rawBody), | ||
| 233 | }; | ||
| 234 | } | ||
| 235 | |||
| 236 | export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string { | ||
| 237 | const rangeLabel = formatReviewSelectedRangeLabel(target); | ||
| 238 | return [ | ||
| 239 | [ | ||
| 240 | "<review_comment", | ||
| 241 | ` sectionId="${escapeReviewCommentAttribute(target.sectionId)}"`, | ||
| 242 | ` sectionTitle="${escapeReviewCommentAttribute(target.sectionTitle)}"`, | ||
| 243 | ` filePath="${escapeReviewCommentAttribute(target.filePath)}"`, | ||
| 244 | ` startIndex="${target.startIndex}"`, | ||
| 245 | ` endIndex="${target.endIndex}"`, | ||
| 246 | ` rangeLabel="${escapeReviewCommentAttribute(rangeLabel)}"`, | ||
| 247 | ">", | ||
| 248 | ].join(""), | ||
| 249 | comment.trim(), | ||
| 250 | "```diff", | ||
| 251 | formatReviewSelectedDiff(target), | ||
| 252 | "```", | ||
| 253 | "</review_comment>", | ||
| 254 | ].join("\n"); | ||
| 255 | } | ||
| 256 | |||
| 257 | export function countReviewCommentContexts(value: string): number { | ||
| 258 | return Array.from(value.matchAll(/<review_comment\b/g)).length; | ||
| 259 | } | ||
| 260 | |||
| 261 | export function parseReviewInlineComments(value: string): ReadonlyArray<ReviewInlineComment> { | ||
| 262 | const comments: ReviewInlineComment[] = []; | ||
| 263 | for (const [index, match] of Array.from(value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)).entries()) { | ||
| 264 | const comment = parseReviewInlineComment(match[1] ?? "", match[2] ?? "", index); | ||
| 265 | if (!comment) { | ||
| 266 | continue; | ||
| 267 | } | ||
| 268 | |||
| 269 | comments.push(comment); | ||
| 270 | } | ||
| 271 | return comments; | ||
| 272 | } | ||
| 273 | |||
| 274 | export function parseReviewCommentMessageSegments( | ||
| 275 | value: string, | ||
| 276 | ): ReadonlyArray<ReviewCommentMessageSegment> { | ||
| 277 | const segments: ReviewCommentMessageSegment[] = []; | ||
| 278 | let cursor = 0; | ||
| 279 | let parsedCommentIndex = 0; | ||
| 280 | |||
| 281 | for (const match of value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) { | ||
| 282 | const matchIndex = match.index ?? 0; | ||
| 283 | const beforeText = value.slice(cursor, matchIndex); | ||
| 284 | if (beforeText.length > 0) { | ||
| 285 | segments.push({ | ||
| 286 | kind: "text", | ||
| 287 | id: `review-comment-text:${cursor}`, | ||
| 288 | text: beforeText, | ||
| 289 | }); | ||
| 290 | } | ||
| 291 | |||
| 292 | const comment = parseReviewInlineComment(match[1] ?? "", match[2] ?? "", parsedCommentIndex); | ||
| 293 | if (comment) { | ||
| 294 | segments.push({ kind: "review-comment", comment }); | ||
| 295 | parsedCommentIndex += 1; | ||
| 296 | } else { | ||
| 297 | segments.push({ | ||
| 298 | kind: "text", | ||
| 299 | id: `review-comment-invalid:${matchIndex}`, | ||
| 300 | text: match[0], | ||
| 301 | }); | ||
| 302 | } | ||
| 303 | |||
| 304 | cursor = matchIndex + match[0].length; | ||
| 305 | } | ||
| 306 | |||
| 307 | const rest = value.slice(cursor); | ||
| 308 | if (rest.length > 0) { | ||
| 309 | segments.push({ | ||
| 310 | kind: "text", | ||
| 311 | id: `review-comment-text:${cursor}`, | ||
| 312 | text: rest, | ||
| 313 | }); | ||
| 314 | } | ||
| 315 | |||
| 316 | return segments; | ||
| 317 | } | ||
apps/mobile/src/features/review/reviewDiffPreviewState.ts created+110| ... | @@ -0,0 +1,110 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { EnvironmentId, ReviewDiffPreviewResult } from "@t3tools/contracts"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 7 | import { useCallback, useMemo } from "react"; | ||
| 8 | |||
| 9 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 10 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | ||
| 11 | |||
| 12 | const REVIEW_DIFF_PREVIEW_STALE_TIME_MS = 5_000; | ||
| 13 | const REVIEW_DIFF_PREVIEW_IDLE_TTL_MS = 5 * 60_000; | ||
| 14 | const REVIEW_DIFF_PREVIEW_KEY_SEPARATOR = "\u001f"; | ||
| 15 | |||
| 16 | export interface ReviewDiffPreviewState { | ||
| 17 | readonly data: ReviewDiffPreviewResult | null; | ||
| 18 | readonly error: string | null; | ||
| 19 | readonly isPending: boolean; | ||
| 20 | readonly refresh: () => void; | ||
| 21 | } | ||
| 22 | |||
| 23 | function makeReviewDiffPreviewKey(input: { | ||
| 24 | readonly environmentId: EnvironmentId; | ||
| 25 | readonly cwd: string; | ||
| 26 | }): string { | ||
| 27 | return `${input.environmentId}${REVIEW_DIFF_PREVIEW_KEY_SEPARATOR}${input.cwd}`; | ||
| 28 | } | ||
| 29 | |||
| 30 | function parseReviewDiffPreviewKey(key: string): { | ||
| 31 | readonly environmentId: EnvironmentId; | ||
| 32 | readonly cwd: string; | ||
| 33 | } { | ||
| 34 | const [environmentId, cwd = ""] = key.split(REVIEW_DIFF_PREVIEW_KEY_SEPARATOR); | ||
| 35 | return { | ||
| 36 | environmentId: environmentId as EnvironmentId, | ||
| 37 | cwd, | ||
| 38 | }; | ||
| 39 | } | ||
| 40 | |||
| 41 | const reviewDiffPreviewAtom = Atom.family((key: string) => | ||
| 42 | Atom.make( | ||
| 43 | Effect.promise(async (): Promise<ReviewDiffPreviewResult> => { | ||
| 44 | const target = parseReviewDiffPreviewKey(key); | ||
| 45 | const client = getEnvironmentClient(target.environmentId); | ||
| 46 | if (!client) { | ||
| 47 | throw new Error("Remote connection is not ready."); | ||
| 48 | } | ||
| 49 | return client.review.getDiffPreview({ cwd: target.cwd }); | ||
| 50 | }), | ||
| 51 | ).pipe( | ||
| 52 | Atom.swr({ | ||
| 53 | staleTime: REVIEW_DIFF_PREVIEW_STALE_TIME_MS, | ||
| 54 | revalidateOnMount: true, | ||
| 55 | }), | ||
| 56 | Atom.setIdleTTL(REVIEW_DIFF_PREVIEW_IDLE_TTL_MS), | ||
| 57 | Atom.withLabel(`mobile:review:diff-preview:${key}`), | ||
| 58 | ), | ||
| 59 | ); | ||
| 60 | |||
| 61 | const EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM = Atom.make( | ||
| 62 | AsyncResult.initial<ReviewDiffPreviewResult, never>(false), | ||
| 63 | ).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:diff-preview:null")); | ||
| 64 | |||
| 65 | function readReviewDiffPreviewError( | ||
| 66 | result: AsyncResult.AsyncResult<ReviewDiffPreviewResult, unknown>, | ||
| 67 | ): string | null { | ||
| 68 | if (result._tag !== "Failure") { | ||
| 69 | return null; | ||
| 70 | } | ||
| 71 | |||
| 72 | const error = Cause.squash(result.cause); | ||
| 73 | return error instanceof Error ? error.message : "Failed to load review diffs."; | ||
| 74 | } | ||
| 75 | |||
| 76 | export function useReviewDiffPreview(input: { | ||
| 77 | readonly environmentId?: EnvironmentId; | ||
| 78 | readonly cwd: string | null; | ||
| 79 | }): ReviewDiffPreviewState { | ||
| 80 | const key = useMemo(() => { | ||
| 81 | if (!input.environmentId || !input.cwd) { | ||
| 82 | return null; | ||
| 83 | } | ||
| 84 | return makeReviewDiffPreviewKey({ environmentId: input.environmentId, cwd: input.cwd }); | ||
| 85 | }, [input.cwd, input.environmentId]); | ||
| 86 | |||
| 87 | const atom = key ? reviewDiffPreviewAtom(key) : null; | ||
| 88 | const result = useAtomValue(atom ?? EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM); | ||
| 89 | const refresh = useCallback(() => { | ||
| 90 | if (atom) { | ||
| 91 | appAtomRegistry.refresh(atom); | ||
| 92 | } | ||
| 93 | }, [atom]); | ||
| 94 | |||
| 95 | if (!atom) { | ||
| 96 | return { | ||
| 97 | data: null, | ||
| 98 | error: null, | ||
| 99 | isPending: false, | ||
| 100 | refresh, | ||
| 101 | }; | ||
| 102 | } | ||
| 103 | |||
| 104 | return { | ||
| 105 | data: Option.getOrNull(AsyncResult.value(result)), | ||
| 106 | error: readReviewDiffPreviewError(result), | ||
| 107 | isPending: result.waiting, | ||
| 108 | refresh, | ||
| 109 | }; | ||
| 110 | } | ||
apps/mobile/src/features/review/reviewDiffRendering.tsx created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | import { Platform, Text as NativeText, View } from "react-native"; | ||
| 2 | |||
| 3 | import { cn } from "../../lib/cn"; | ||
| 4 | |||
| 5 | import type { ReviewRenderableLineRow } from "./reviewModel"; | ||
| 6 | import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; | ||
| 7 | |||
| 8 | export const REVIEW_MONO_FONT_FAMILY = Platform.select({ | ||
| 9 | ios: "ui-monospace", | ||
| 10 | android: "monospace", | ||
| 11 | default: "monospace", | ||
| 12 | }); | ||
| 13 | |||
| 14 | export const REVIEW_DIFF_LINE_HEIGHT = 26; | ||
| 15 | const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2; | ||
| 16 | |||
| 17 | export function renderVisibleWhitespace(value: string): string { | ||
| 18 | const expandedTabs = value.replace(/\t/g, " "); | ||
| 19 | return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0")); | ||
| 20 | } | ||
| 21 | |||
| 22 | export function changeTone(change: ReviewRenderableLineRow["change"]): string { | ||
| 23 | if (change === "add") return "bg-emerald-500/10"; | ||
| 24 | if (change === "delete") return "bg-rose-500/10"; | ||
| 25 | return "bg-card"; | ||
| 26 | } | ||
| 27 | |||
| 28 | export function changeBarTone(change: ReviewRenderableLineRow["change"]): string { | ||
| 29 | if (change === "add") return "bg-emerald-400"; | ||
| 30 | if (change === "delete") return "bg-rose-400"; | ||
| 31 | return "bg-border/50"; | ||
| 32 | } | ||
| 33 | |||
| 34 | function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | undefined { | ||
| 35 | if (change === "add") return "rgba(16, 185, 129, 0.24)"; | ||
| 36 | if (change === "delete") return "rgba(244, 63, 94, 0.24)"; | ||
| 37 | return undefined; | ||
| 38 | } | ||
| 39 | |||
| 40 | export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRow["change"] }) { | ||
| 41 | if (props.change === "delete") { | ||
| 42 | return ( | ||
| 43 | <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}> | ||
| 44 | <View> | ||
| 45 | {Array.from({ length: REVIEW_DELETE_STRIPE_COUNT }, (_, index) => ( | ||
| 46 | <View key={index}> | ||
| 47 | <View className="w-[5px] bg-rose-400" style={{ height: 1 }} /> | ||
| 48 | <View style={{ height: 1 }} /> | ||
| 49 | </View> | ||
| 50 | ))} | ||
| 51 | </View> | ||
| 52 | </View> | ||
| 53 | ); | ||
| 54 | } | ||
| 55 | |||
| 56 | return ( | ||
| 57 | <View className="w-[5px] overflow-hidden" style={{ height: REVIEW_DIFF_LINE_HEIGHT }}> | ||
| 58 | <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} /> | ||
| 59 | </View> | ||
| 60 | ); | ||
| 61 | } | ||
| 62 | |||
| 63 | export function DiffTokenText(props: { | ||
| 64 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; | ||
| 65 | readonly fallback: string; | ||
| 66 | readonly change?: ReviewRenderableLineRow["change"]; | ||
| 67 | readonly className?: string; | ||
| 68 | }) { | ||
| 69 | if (!props.tokens || props.tokens.length === 0) { | ||
| 70 | return ( | ||
| 71 | <NativeText | ||
| 72 | numberOfLines={1} | ||
| 73 | selectable | ||
| 74 | className={cn("text-[13px] leading-[17px] font-medium text-foreground", props.className)} | ||
| 75 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | ||
| 76 | > | ||
| 77 | {renderVisibleWhitespace(props.fallback || " ")} | ||
| 78 | </NativeText> | ||
| 79 | ); | ||
| 80 | } | ||
| 81 | |||
| 82 | return ( | ||
| 83 | <NativeText | ||
| 84 | numberOfLines={1} | ||
| 85 | selectable | ||
| 86 | className={cn("text-[13px] leading-[17px] font-medium text-foreground", props.className)} | ||
| 87 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} | ||
| 88 | > | ||
| 89 | {(() => { | ||
| 90 | let offset = 0; | ||
| 91 | |||
| 92 | return props.tokens.map((token) => { | ||
| 93 | const start = offset; | ||
| 94 | offset += token.content.length; | ||
| 95 | |||
| 96 | const fontWeight = | ||
| 97 | token.fontStyle !== null && (token.fontStyle & 2) === 2 | ||
| 98 | ? ("700" as const) | ||
| 99 | : ("500" as const); | ||
| 100 | const fontStyle = | ||
| 101 | token.fontStyle !== null && (token.fontStyle & 1) === 1 | ||
| 102 | ? ("italic" as const) | ||
| 103 | : ("normal" as const); | ||
| 104 | |||
| 105 | return ( | ||
| 106 | <NativeText | ||
| 107 | key={`${start}:${token.content.length}:${token.color ?? ""}:${token.fontStyle ?? ""}`} | ||
| 108 | selectable | ||
| 109 | style={{ | ||
| 110 | color: token.color ?? undefined, | ||
| 111 | fontFamily: REVIEW_MONO_FONT_FAMILY, | ||
| 112 | fontWeight, | ||
| 113 | fontStyle, | ||
| 114 | backgroundColor: | ||
| 115 | token.diffHighlight && props.change | ||
| 116 | ? diffHighlightColor(props.change) | ||
| 117 | : undefined, | ||
| 118 | borderRadius: token.diffHighlight ? 4 : undefined, | ||
| 119 | }} | ||
| 120 | > | ||
| 121 | {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} | ||
| 122 | </NativeText> | ||
| 123 | ); | ||
| 124 | }); | ||
| 125 | })()} | ||
| 126 | </NativeText> | ||
| 127 | ); | ||
| 128 | } | ||
apps/mobile/src/features/review/reviewFileVisibility.test.ts created+50| ... | @@ -0,0 +1,50 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | getDefaultReviewExpandedFileIds, | ||
| 5 | getValidExplicitReviewFileIds, | ||
| 6 | getValidReviewFileIds, | ||
| 7 | removeReviewFileId, | ||
| 8 | toggleReviewFileId, | ||
| 9 | } from "./reviewFileVisibility"; | ||
| 10 | import type { ReviewRenderableFile } from "./reviewModel"; | ||
| 11 | |||
| 12 | function makeFile(id: string): ReviewRenderableFile { | ||
| 13 | return { | ||
| 14 | id, | ||
| 15 | cacheKey: id, | ||
| 16 | path: id, | ||
| 17 | previousPath: null, | ||
| 18 | changeType: "change", | ||
| 19 | additions: 0, | ||
| 20 | deletions: 0, | ||
| 21 | languageHint: null, | ||
| 22 | additionLines: [], | ||
| 23 | deletionLines: [], | ||
| 24 | rows: [], | ||
| 25 | }; | ||
| 26 | } | ||
| 27 | |||
| 28 | describe("review file visibility", () => { | ||
| 29 | const files = [makeFile("a.ts"), makeFile("b.ts")]; | ||
| 30 | |||
| 31 | it("defaults expanded files to every renderable file", () => { | ||
| 32 | expect(getDefaultReviewExpandedFileIds(files)).toEqual(["a.ts", "b.ts"]); | ||
| 33 | expect(getValidReviewFileIds(files, undefined)).toEqual(["a.ts", "b.ts"]); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("filters stale cached file ids", () => { | ||
| 37 | expect(getValidReviewFileIds(files, ["missing.ts", "b.ts"])).toEqual(["b.ts"]); | ||
| 38 | expect(getValidExplicitReviewFileIds(files, undefined)).toEqual([]); | ||
| 39 | expect(getValidExplicitReviewFileIds(files, ["a.ts", "missing.ts"])).toEqual(["a.ts"]); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("toggles and removes ids without mutating the original array", () => { | ||
| 43 | const original = ["a.ts"]; | ||
| 44 | |||
| 45 | expect(toggleReviewFileId(original, "b.ts")).toEqual(["a.ts", "b.ts"]); | ||
| 46 | expect(toggleReviewFileId(original, "a.ts")).toEqual([]); | ||
| 47 | expect(removeReviewFileId(original, "a.ts")).toEqual([]); | ||
| 48 | expect(original).toEqual(["a.ts"]); | ||
| 49 | }); | ||
| 50 | }); | ||
apps/mobile/src/features/review/reviewFileVisibility.ts created+116| ... | @@ -0,0 +1,116 @@ | ||
| 1 | import { useCallback, useMemo } from "react"; | ||
| 2 | |||
| 3 | import { updateReviewExpandedFileIds, updateReviewViewedFileIds } from "./reviewState"; | ||
| 4 | import type { ReviewRenderableFile } from "./reviewModel"; | ||
| 5 | |||
| 6 | export function getDefaultReviewExpandedFileIds( | ||
| 7 | files: ReadonlyArray<ReviewRenderableFile>, | ||
| 8 | ): ReadonlyArray<string> { | ||
| 9 | return files.map((file) => file.id); | ||
| 10 | } | ||
| 11 | |||
| 12 | export function getValidReviewFileIds( | ||
| 13 | files: ReadonlyArray<ReviewRenderableFile>, | ||
| 14 | fileIds: ReadonlyArray<string> | undefined, | ||
| 15 | ): ReadonlyArray<string> { | ||
| 16 | if (fileIds === undefined) { | ||
| 17 | return getDefaultReviewExpandedFileIds(files); | ||
| 18 | } | ||
| 19 | |||
| 20 | const fileIdSet = new Set(files.map((file) => file.id)); | ||
| 21 | return fileIds.filter((id) => fileIdSet.has(id)); | ||
| 22 | } | ||
| 23 | |||
| 24 | export function getValidExplicitReviewFileIds( | ||
| 25 | files: ReadonlyArray<ReviewRenderableFile>, | ||
| 26 | fileIds: ReadonlyArray<string> | undefined, | ||
| 27 | ): ReadonlyArray<string> { | ||
| 28 | if (fileIds === undefined) { | ||
| 29 | return []; | ||
| 30 | } | ||
| 31 | |||
| 32 | return getValidReviewFileIds(files, fileIds); | ||
| 33 | } | ||
| 34 | |||
| 35 | export function toggleReviewFileId( | ||
| 36 | fileIds: ReadonlyArray<string>, | ||
| 37 | fileId: string, | ||
| 38 | ): ReadonlyArray<string> { | ||
| 39 | return fileIds.includes(fileId) ? fileIds.filter((id) => id !== fileId) : [...fileIds, fileId]; | ||
| 40 | } | ||
| 41 | |||
| 42 | export function removeReviewFileId( | ||
| 43 | fileIds: ReadonlyArray<string>, | ||
| 44 | fileId: string, | ||
| 45 | ): ReadonlyArray<string> { | ||
| 46 | return fileIds.includes(fileId) ? fileIds.filter((id) => id !== fileId) : fileIds; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function useReviewFileVisibility(input: { | ||
| 50 | readonly threadKey: string | null; | ||
| 51 | readonly sectionId: string | null; | ||
| 52 | readonly files: ReadonlyArray<ReviewRenderableFile>; | ||
| 53 | readonly cachedExpandedFileIds: ReadonlyArray<string> | undefined; | ||
| 54 | readonly cachedViewedFileIds: ReadonlyArray<string> | undefined; | ||
| 55 | }) { | ||
| 56 | const { cachedExpandedFileIds, cachedViewedFileIds, files, sectionId, threadKey } = input; | ||
| 57 | |||
| 58 | const expandedFileIds = useMemo( | ||
| 59 | () => getValidReviewFileIds(files, cachedExpandedFileIds), | ||
| 60 | [cachedExpandedFileIds, files], | ||
| 61 | ); | ||
| 62 | const viewedFileIds = useMemo( | ||
| 63 | () => getValidExplicitReviewFileIds(files, cachedViewedFileIds), | ||
| 64 | [cachedViewedFileIds, files], | ||
| 65 | ); | ||
| 66 | const collapsedFileIds = useMemo(() => { | ||
| 67 | const expandedFileIdSet = new Set(expandedFileIds); | ||
| 68 | return files.reduce<string[]>((fileIds, file) => { | ||
| 69 | if (!expandedFileIdSet.has(file.id)) { | ||
| 70 | fileIds.push(file.id); | ||
| 71 | } | ||
| 72 | return fileIds; | ||
| 73 | }, []); | ||
| 74 | }, [expandedFileIds, files]); | ||
| 75 | |||
| 76 | const toggleExpandedFile = useCallback( | ||
| 77 | (fileId: string) => { | ||
| 78 | if (!threadKey || !sectionId) { | ||
| 79 | return; | ||
| 80 | } | ||
| 81 | |||
| 82 | updateReviewExpandedFileIds(threadKey, sectionId, (existing) => | ||
| 83 | toggleReviewFileId(getValidReviewFileIds(files, existing), fileId), | ||
| 84 | ); | ||
| 85 | }, | ||
| 86 | [files, sectionId, threadKey], | ||
| 87 | ); | ||
| 88 | |||
| 89 | const toggleViewedFile = useCallback( | ||
| 90 | (fileId: string) => { | ||
| 91 | if (!threadKey || !sectionId) { | ||
| 92 | return; | ||
| 93 | } | ||
| 94 | |||
| 95 | const shouldCollapse = !viewedFileIds.includes(fileId); | ||
| 96 | updateReviewViewedFileIds(threadKey, sectionId, (existing) => | ||
| 97 | toggleReviewFileId(getValidExplicitReviewFileIds(files, existing), fileId), | ||
| 98 | ); | ||
| 99 | |||
| 100 | if (shouldCollapse) { | ||
| 101 | updateReviewExpandedFileIds(threadKey, sectionId, (existing) => | ||
| 102 | removeReviewFileId(getValidReviewFileIds(files, existing), fileId), | ||
| 103 | ); | ||
| 104 | } | ||
| 105 | }, | ||
| 106 | [files, sectionId, threadKey, viewedFileIds], | ||
| 107 | ); | ||
| 108 | |||
| 109 | return { | ||
| 110 | expandedFileIds, | ||
| 111 | viewedFileIds, | ||
| 112 | collapsedFileIds, | ||
| 113 | toggleExpandedFile, | ||
| 114 | toggleViewedFile, | ||
| 115 | }; | ||
| 116 | } | ||
apps/mobile/src/features/review/reviewHighlighterEngine.test.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | resolveReviewHighlighterEngine, | ||
| 5 | resolveReviewHighlighterEnginePreference, | ||
| 6 | } from "./reviewHighlighterEngine"; | ||
| 7 | |||
| 8 | describe("resolveReviewHighlighterEnginePreference", () => { | ||
| 9 | it("defaults invalid values to native", () => { | ||
| 10 | expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("native"); | ||
| 11 | expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("native"); | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("accepts supported values", () => { | ||
| 15 | expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript"); | ||
| 16 | expect(resolveReviewHighlighterEnginePreference("js")).toBe("javascript"); | ||
| 17 | expect(resolveReviewHighlighterEnginePreference("native")).toBe("native"); | ||
| 18 | }); | ||
| 19 | }); | ||
| 20 | |||
| 21 | describe("resolveReviewHighlighterEngine", () => { | ||
| 22 | it("uses javascript when explicitly requested", () => { | ||
| 23 | expect(resolveReviewHighlighterEngine("javascript", true)).toBe("javascript"); | ||
| 24 | expect(resolveReviewHighlighterEngine("javascript", false)).toBe("javascript"); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("uses native when available for native preference", () => { | ||
| 28 | expect(resolveReviewHighlighterEngine("native", true)).toBe("native"); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("falls back to javascript when native is unavailable", () => { | ||
| 32 | expect(resolveReviewHighlighterEngine("native", false)).toBe("javascript"); | ||
| 33 | }); | ||
| 34 | }); | ||
apps/mobile/src/features/review/reviewHighlighterEngine.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | export type ReviewHighlighterEnginePreference = "javascript" | "native"; | ||
| 2 | export type ReviewHighlighterEngine = "javascript" | "native"; | ||
| 3 | |||
| 4 | export function resolveReviewHighlighterEnginePreference( | ||
| 5 | value: string | undefined, | ||
| 6 | ): ReviewHighlighterEnginePreference { | ||
| 7 | switch (value) { | ||
| 8 | case "js": | ||
| 9 | case "javascript": | ||
| 10 | return "javascript"; | ||
| 11 | case "native": | ||
| 12 | return "native"; | ||
| 13 | default: | ||
| 14 | return "native"; | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | export function resolveReviewHighlighterEngine( | ||
| 19 | preference: ReviewHighlighterEnginePreference, | ||
| 20 | nativeAvailable: boolean, | ||
| 21 | ): ReviewHighlighterEngine { | ||
| 22 | if (preference === "javascript") { | ||
| 23 | return "javascript"; | ||
| 24 | } | ||
| 25 | |||
| 26 | if (nativeAvailable) { | ||
| 27 | return "native"; | ||
| 28 | } | ||
| 29 | |||
| 30 | return "javascript"; | ||
| 31 | } | ||
apps/mobile/src/features/review/reviewHighlighterState.test.ts created+75| ... | @@ -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 | }); | ||
apps/mobile/src/features/review/reviewHighlighterState.ts created+154| ... | @@ -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 | } | ||
apps/mobile/src/features/review/reviewModel.test.ts created+354| ... | @@ -0,0 +1,354 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | MessageId, | ||
| 5 | TurnId, | ||
| 6 | type OrchestrationCheckpointSummary, | ||
| 7 | type ReviewDiffPreviewSource, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | buildReviewListItems, | ||
| 12 | buildReviewParsedDiff, | ||
| 13 | buildReviewSectionItems, | ||
| 14 | getDefaultReviewSectionId, | ||
| 15 | getReviewFilePreviewState, | ||
| 16 | getReviewSectionIdForCheckpoint, | ||
| 17 | type ReviewRenderableFile, | ||
| 18 | } from "./reviewModel"; | ||
| 19 | |||
| 20 | function makeCheckpoint( | ||
| 21 | input: Partial<OrchestrationCheckpointSummary> & | ||
| 22 | Pick<OrchestrationCheckpointSummary, "turnId" | "checkpointTurnCount" | "completedAt">, | ||
| 23 | ): OrchestrationCheckpointSummary { | ||
| 24 | return { | ||
| 25 | checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any, | ||
| 26 | status: "ready", | ||
| 27 | files: [], | ||
| 28 | assistantMessageId: MessageId.make(`msg-${input.checkpointTurnCount}`), | ||
| 29 | ...input, | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | |||
| 33 | function makeRenderableFile( | ||
| 34 | input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">, | ||
| 35 | ): ReviewRenderableFile { | ||
| 36 | return { | ||
| 37 | id: input.path, | ||
| 38 | cacheKey: input.path, | ||
| 39 | previousPath: null, | ||
| 40 | changeType: "new", | ||
| 41 | additions: 0, | ||
| 42 | deletions: 0, | ||
| 43 | languageHint: null, | ||
| 44 | additionLines: [], | ||
| 45 | deletionLines: [], | ||
| 46 | rows: [], | ||
| 47 | ...input, | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | |||
| 51 | describe("buildReviewSectionItems", () => { | ||
| 52 | it("keeps one chip per checkpoint and appends git sources", () => { | ||
| 53 | const checkpoints = [ | ||
| 54 | makeCheckpoint({ | ||
| 55 | turnId: TurnId.make("turn-1"), | ||
| 56 | checkpointTurnCount: 1, | ||
| 57 | completedAt: "2026-04-01T00:00:00.000Z", | ||
| 58 | }), | ||
| 59 | makeCheckpoint({ | ||
| 60 | turnId: TurnId.make("turn-2"), | ||
| 61 | checkpointTurnCount: 2, | ||
| 62 | completedAt: "2026-04-02T00:00:00.000Z", | ||
| 63 | }), | ||
| 64 | ]; | ||
| 65 | const gitSections: ReviewDiffPreviewSource[] = [ | ||
| 66 | { | ||
| 67 | id: "working-tree", | ||
| 68 | kind: "working-tree", | ||
| 69 | title: "Dirty worktree", | ||
| 70 | baseRef: "HEAD", | ||
| 71 | headRef: null, | ||
| 72 | diff: "diff --git a/a.ts b/a.ts", | ||
| 73 | diffHash: "hash-dirty", | ||
| 74 | truncated: false, | ||
| 75 | }, | ||
| 76 | { | ||
| 77 | id: "branch-range", | ||
| 78 | kind: "branch-range", | ||
| 79 | title: "Against main", | ||
| 80 | baseRef: "main", | ||
| 81 | headRef: "feature", | ||
| 82 | diff: "diff --git a/a.ts b/a.ts", | ||
| 83 | diffHash: "hash-base", | ||
| 84 | truncated: false, | ||
| 85 | }, | ||
| 86 | ]; | ||
| 87 | |||
| 88 | const loadedTurnId = getReviewSectionIdForCheckpoint(checkpoints[0]); | ||
| 89 | const items = buildReviewSectionItems({ | ||
| 90 | checkpoints, | ||
| 91 | gitSections, | ||
| 92 | turnDiffById: { | ||
| 93 | [loadedTurnId]: "diff --git a/loaded.ts b/loaded.ts", | ||
| 94 | }, | ||
| 95 | loadingTurnIds: { | ||
| 96 | [getReviewSectionIdForCheckpoint(checkpoints[1])]: true, | ||
| 97 | }, | ||
| 98 | loadingGitSections: false, | ||
| 99 | }); | ||
| 100 | |||
| 101 | expect(items.map((item) => item.id)).toEqual([ | ||
| 102 | "turn:2", | ||
| 103 | "turn:1", | ||
| 104 | "git:working-tree", | ||
| 105 | "git:branch-range", | ||
| 106 | ]); | ||
| 107 | expect(items[0]).toMatchObject({ isLoading: true, diff: null }); | ||
| 108 | expect(items[1]).toMatchObject({ | ||
| 109 | isLoading: false, | ||
| 110 | diff: expect.stringContaining("loaded.ts"), | ||
| 111 | }); | ||
| 112 | expect(getDefaultReviewSectionId(items)).toBe("turn:2"); | ||
| 113 | }); | ||
| 114 | |||
| 115 | it("shows dirty worktree while git preview is loading", () => { | ||
| 116 | const items = buildReviewSectionItems({ | ||
| 117 | checkpoints: [], | ||
| 118 | gitSections: [], | ||
| 119 | turnDiffById: {}, | ||
| 120 | loadingTurnIds: {}, | ||
| 121 | loadingGitSections: true, | ||
| 122 | }); | ||
| 123 | |||
| 124 | expect(items).toEqual([ | ||
| 125 | expect.objectContaining({ | ||
| 126 | id: "git:working-tree", | ||
| 127 | kind: "working-tree", | ||
| 128 | title: "Dirty worktree", | ||
| 129 | subtitle: "Tracked, staged, and untracked worktree changes", | ||
| 130 | diff: null, | ||
| 131 | isLoading: true, | ||
| 132 | }), | ||
| 133 | ]); | ||
| 134 | expect(getDefaultReviewSectionId(items)).toBe("git:working-tree"); | ||
| 135 | }); | ||
| 136 | }); | ||
| 137 | |||
| 138 | describe("buildReviewParsedDiff", () => { | ||
| 139 | it("builds renderable rows from a unified patch", () => { | ||
| 140 | const parsed = buildReviewParsedDiff( | ||
| 141 | [ | ||
| 142 | "diff --git a/apps/mobile/src/a.ts b/apps/mobile/src/a.ts", | ||
| 143 | "index 1111111..2222222 100644", | ||
| 144 | "--- a/apps/mobile/src/a.ts", | ||
| 145 | "+++ b/apps/mobile/src/a.ts", | ||
| 146 | "@@ -1,2 +1,3 @@", | ||
| 147 | "-const before = 1;", | ||
| 148 | "+const after = 2;", | ||
| 149 | "+console.log(after);", | ||
| 150 | " return true;", | ||
| 151 | ].join("\n"), | ||
| 152 | "unit", | ||
| 153 | ); | ||
| 154 | |||
| 155 | expect(parsed.kind).toBe("files"); | ||
| 156 | if (parsed.kind !== "files") { | ||
| 157 | return; | ||
| 158 | } | ||
| 159 | |||
| 160 | expect(parsed.fileCount).toBe(1); | ||
| 161 | expect(parsed.additions).toBe(2); | ||
| 162 | expect(parsed.deletions).toBe(1); | ||
| 163 | expect(parsed.files[0]).toMatchObject({ | ||
| 164 | path: "apps/mobile/src/a.ts", | ||
| 165 | additions: 2, | ||
| 166 | deletions: 1, | ||
| 167 | }); | ||
| 168 | expect(parsed.files[0]?.rows).toEqual([ | ||
| 169 | expect.objectContaining({ kind: "hunk", header: "@@ -1,2 +1,3 @@" }), | ||
| 170 | expect.objectContaining({ | ||
| 171 | kind: "line", | ||
| 172 | change: "delete", | ||
| 173 | oldLineNumber: 1, | ||
| 174 | newLineNumber: null, | ||
| 175 | content: "const before = 1;", | ||
| 176 | comparison: { change: "add", tokenIndex: 0 }, | ||
| 177 | }), | ||
| 178 | expect.objectContaining({ | ||
| 179 | kind: "line", | ||
| 180 | change: "add", | ||
| 181 | oldLineNumber: null, | ||
| 182 | newLineNumber: 1, | ||
| 183 | content: "const after = 2;", | ||
| 184 | comparison: { change: "delete", tokenIndex: 0 }, | ||
| 185 | }), | ||
| 186 | expect.objectContaining({ | ||
| 187 | kind: "line", | ||
| 188 | change: "add", | ||
| 189 | oldLineNumber: null, | ||
| 190 | newLineNumber: 2, | ||
| 191 | content: "console.log(after);", | ||
| 192 | comparison: null, | ||
| 193 | }), | ||
| 194 | expect.objectContaining({ | ||
| 195 | kind: "line", | ||
| 196 | change: "context", | ||
| 197 | oldLineNumber: 2, | ||
| 198 | newLineNumber: 3, | ||
| 199 | content: "return true;", | ||
| 200 | comparison: null, | ||
| 201 | }), | ||
| 202 | ]); | ||
| 203 | }); | ||
| 204 | |||
| 205 | it("treats truncated patches as partial diffs instead of failing", () => { | ||
| 206 | const parsed = buildReviewParsedDiff( | ||
| 207 | [ | ||
| 208 | "diff --git a/apps/mobile/src/a.ts b/apps/mobile/src/a.ts", | ||
| 209 | "index 1111111..2222222 100644", | ||
| 210 | "--- a/apps/mobile/src/a.ts", | ||
| 211 | "+++ b/apps/mobile/src/a.ts", | ||
| 212 | "@@ -1 +1,2 @@", | ||
| 213 | " const before = 1;", | ||
| 214 | "+const after = 2;", | ||
| 215 | "", | ||
| 216 | "[truncated]", | ||
| 217 | ].join("\n"), | ||
| 218 | "unit-truncated", | ||
| 219 | ); | ||
| 220 | |||
| 221 | expect(parsed.kind).toBe("files"); | ||
| 222 | if (parsed.kind !== "files") { | ||
| 223 | return; | ||
| 224 | } | ||
| 225 | |||
| 226 | expect(parsed.notice).toContain("server size cap"); | ||
| 227 | expect(parsed.fileCount).toBe(1); | ||
| 228 | expect(parsed.files[0]?.rows[0]).toMatchObject({ | ||
| 229 | kind: "hunk", | ||
| 230 | header: "@@ -1,1 +1,2 @@", | ||
| 231 | }); | ||
| 232 | }); | ||
| 233 | |||
| 234 | it("suppresses preview for non-text file formats", () => { | ||
| 235 | const preview = getReviewFilePreviewState( | ||
| 236 | makeRenderableFile({ | ||
| 237 | path: "apps/mobile/assets/icon.png", | ||
| 238 | }), | ||
| 239 | ); | ||
| 240 | |||
| 241 | expect(preview).toMatchObject({ | ||
| 242 | kind: "suppressed", | ||
| 243 | reason: "non-text", | ||
| 244 | title: "Non-text file", | ||
| 245 | actionLabel: null, | ||
| 246 | }); | ||
| 247 | }); | ||
| 248 | |||
| 249 | it("suppresses large diffs until explicitly requested", () => { | ||
| 250 | const preview = getReviewFilePreviewState( | ||
| 251 | makeRenderableFile({ | ||
| 252 | path: "apps/mobile/src/big.ts", | ||
| 253 | rows: Array.from({ length: 401 }, (_, index) => ({ | ||
| 254 | kind: "line" as const, | ||
| 255 | id: `line-${index}`, | ||
| 256 | change: "add" as const, | ||
| 257 | oldLineNumber: null, | ||
| 258 | newLineNumber: index + 1, | ||
| 259 | content: `const line${index} = ${index};`, | ||
| 260 | additionTokenIndex: index, | ||
| 261 | deletionTokenIndex: null, | ||
| 262 | comparison: null, | ||
| 263 | })), | ||
| 264 | }), | ||
| 265 | ); | ||
| 266 | |||
| 267 | expect(preview).toMatchObject({ | ||
| 268 | kind: "suppressed", | ||
| 269 | reason: "large", | ||
| 270 | title: "Large diff", | ||
| 271 | actionLabel: "Load diff", | ||
| 272 | }); | ||
| 273 | }); | ||
| 274 | |||
| 275 | it("flattens expanded file rows into virtualized review items", () => { | ||
| 276 | const file = makeRenderableFile({ | ||
| 277 | path: "apps/mobile/src/a.ts", | ||
| 278 | rows: [ | ||
| 279 | { | ||
| 280 | kind: "hunk", | ||
| 281 | id: "hunk-1", | ||
| 282 | header: "@@ -1,1 +1,2 @@", | ||
| 283 | context: null, | ||
| 284 | }, | ||
| 285 | { | ||
| 286 | kind: "line", | ||
| 287 | id: "line-1", | ||
| 288 | change: "add", | ||
| 289 | oldLineNumber: null, | ||
| 290 | newLineNumber: 1, | ||
| 291 | content: "const after = 2;", | ||
| 292 | additionTokenIndex: 0, | ||
| 293 | deletionTokenIndex: null, | ||
| 294 | comparison: null, | ||
| 295 | }, | ||
| 296 | ], | ||
| 297 | }); | ||
| 298 | |||
| 299 | const items = buildReviewListItems({ | ||
| 300 | files: [file], | ||
| 301 | expandedFileIds: [file.id], | ||
| 302 | revealedLargeFileIds: [], | ||
| 303 | }); | ||
| 304 | |||
| 305 | expect(items).toEqual([ | ||
| 306 | expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), | ||
| 307 | expect.objectContaining({ | ||
| 308 | kind: "hunk", | ||
| 309 | fileId: file.id, | ||
| 310 | file, | ||
| 311 | row: file.rows[0], | ||
| 312 | }), | ||
| 313 | expect.objectContaining({ | ||
| 314 | kind: "line", | ||
| 315 | fileId: file.id, | ||
| 316 | file, | ||
| 317 | row: file.rows[1], | ||
| 318 | lineIndex: 0, | ||
| 319 | }), | ||
| 320 | ]); | ||
| 321 | }); | ||
| 322 | |||
| 323 | it("keeps large diffs collapsed into a placeholder item until revealed", () => { | ||
| 324 | const file = makeRenderableFile({ | ||
| 325 | path: "apps/mobile/src/big.ts", | ||
| 326 | rows: Array.from({ length: 401 }, (_, index) => ({ | ||
| 327 | kind: "line" as const, | ||
| 328 | id: `line-${index}`, | ||
| 329 | change: "add" as const, | ||
| 330 | oldLineNumber: null, | ||
| 331 | newLineNumber: index + 1, | ||
| 332 | content: `const line${index} = ${index};`, | ||
| 333 | additionTokenIndex: index, | ||
| 334 | deletionTokenIndex: null, | ||
| 335 | comparison: null, | ||
| 336 | })), | ||
| 337 | }); | ||
| 338 | |||
| 339 | const items = buildReviewListItems({ | ||
| 340 | files: [file], | ||
| 341 | expandedFileIds: [file.id], | ||
| 342 | revealedLargeFileIds: [], | ||
| 343 | }); | ||
| 344 | |||
| 345 | expect(items).toEqual([ | ||
| 346 | expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), | ||
| 347 | expect.objectContaining({ | ||
| 348 | kind: "file-suppressed", | ||
| 349 | fileId: file.id, | ||
| 350 | actionLabel: "Load diff", | ||
| 351 | }), | ||
| 352 | ]); | ||
| 353 | }); | ||
| 354 | }); | ||
apps/mobile/src/features/review/reviewModel.ts created+639| ... | @@ -0,0 +1,639 @@ | ||
| 1 | import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; | ||
| 2 | import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; | ||
| 3 | import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts"; | ||
| 4 | import * as Arr from "effect/Array"; | ||
| 5 | import { pipe } from "effect/Function"; | ||
| 6 | import * as Order from "effect/Order"; | ||
| 7 | |||
| 8 | export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; | ||
| 9 | |||
| 10 | const DIRTY_WORKTREE_SECTION_ID = "git:working-tree"; | ||
| 11 | const DIRTY_WORKTREE_TITLE = "Dirty worktree"; | ||
| 12 | const DIRTY_WORKTREE_SUBTITLE = "Tracked, staged, and untracked worktree changes"; | ||
| 13 | |||
| 14 | export interface ReviewSectionItem { | ||
| 15 | readonly id: string; | ||
| 16 | readonly kind: ReviewSectionKind; | ||
| 17 | readonly title: string; | ||
| 18 | readonly subtitle: string | null; | ||
| 19 | readonly diff: string | null; | ||
| 20 | readonly isLoading: boolean; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface ReviewRenderableHunkRow { | ||
| 24 | readonly kind: "hunk"; | ||
| 25 | readonly id: string; | ||
| 26 | readonly header: string; | ||
| 27 | readonly context: string | null; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface ReviewRenderableLineRow { | ||
| 31 | readonly kind: "line"; | ||
| 32 | readonly id: string; | ||
| 33 | readonly change: "context" | "add" | "delete"; | ||
| 34 | readonly oldLineNumber: number | null; | ||
| 35 | readonly newLineNumber: number | null; | ||
| 36 | readonly content: string; | ||
| 37 | readonly additionTokenIndex: number | null; | ||
| 38 | readonly deletionTokenIndex: number | null; | ||
| 39 | readonly comparison: { | ||
| 40 | readonly change: "add" | "delete"; | ||
| 41 | readonly tokenIndex: number; | ||
| 42 | } | null; | ||
| 43 | } | ||
| 44 | |||
| 45 | export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow; | ||
| 46 | |||
| 47 | export interface ReviewRenderableFile { | ||
| 48 | readonly id: string; | ||
| 49 | readonly cacheKey: string; | ||
| 50 | readonly path: string; | ||
| 51 | readonly previousPath: string | null; | ||
| 52 | readonly changeType: ChangeTypes; | ||
| 53 | readonly additions: number; | ||
| 54 | readonly deletions: number; | ||
| 55 | readonly languageHint: string | null; | ||
| 56 | readonly additionLines: ReadonlyArray<string>; | ||
| 57 | readonly deletionLines: ReadonlyArray<string>; | ||
| 58 | readonly rows: ReadonlyArray<ReviewRenderableRow>; | ||
| 59 | } | ||
| 60 | |||
| 61 | export interface ReviewFileHeaderListItem { | ||
| 62 | readonly kind: "file-header"; | ||
| 63 | readonly id: string; | ||
| 64 | readonly fileId: string; | ||
| 65 | readonly file: ReviewRenderableFile; | ||
| 66 | readonly expanded: boolean; | ||
| 67 | } | ||
| 68 | |||
| 69 | export interface ReviewFileSuppressedListItem { | ||
| 70 | readonly kind: "file-suppressed"; | ||
| 71 | readonly id: string; | ||
| 72 | readonly fileId: string; | ||
| 73 | readonly message: string; | ||
| 74 | readonly actionLabel: string | null; | ||
| 75 | } | ||
| 76 | |||
| 77 | export interface ReviewHunkListItem { | ||
| 78 | readonly kind: "hunk"; | ||
| 79 | readonly id: string; | ||
| 80 | readonly fileId: string; | ||
| 81 | readonly file: ReviewRenderableFile; | ||
| 82 | readonly row: ReviewRenderableHunkRow; | ||
| 83 | } | ||
| 84 | |||
| 85 | export interface ReviewLineListItem { | ||
| 86 | readonly kind: "line"; | ||
| 87 | readonly id: string; | ||
| 88 | readonly fileId: string; | ||
| 89 | readonly file: ReviewRenderableFile; | ||
| 90 | readonly row: ReviewRenderableLineRow; | ||
| 91 | readonly lineIndex: number; | ||
| 92 | } | ||
| 93 | |||
| 94 | export type ReviewListItem = | ||
| 95 | | ReviewFileHeaderListItem | ||
| 96 | | ReviewFileSuppressedListItem | ||
| 97 | | ReviewHunkListItem | ||
| 98 | | ReviewLineListItem; | ||
| 99 | |||
| 100 | export type ReviewFilePreviewState = | ||
| 101 | | { | ||
| 102 | readonly kind: "render"; | ||
| 103 | } | ||
| 104 | | { | ||
| 105 | readonly kind: "suppressed"; | ||
| 106 | readonly reason: "non-text" | "large"; | ||
| 107 | readonly title: string; | ||
| 108 | readonly message: string; | ||
| 109 | readonly actionLabel: string | null; | ||
| 110 | }; | ||
| 111 | |||
| 112 | export type ReviewParsedDiff = | ||
| 113 | | { | ||
| 114 | readonly kind: "empty"; | ||
| 115 | } | ||
| 116 | | { | ||
| 117 | readonly kind: "raw"; | ||
| 118 | readonly text: string; | ||
| 119 | readonly reason: string; | ||
| 120 | readonly notice: string | null; | ||
| 121 | } | ||
| 122 | | { | ||
| 123 | readonly kind: "files"; | ||
| 124 | readonly files: ReadonlyArray<ReviewRenderableFile>; | ||
| 125 | readonly fileCount: number; | ||
| 126 | readonly additions: number; | ||
| 127 | readonly deletions: number; | ||
| 128 | readonly notice: string | null; | ||
| 129 | }; | ||
| 130 | |||
| 131 | function checkpointTitle(checkpoint: OrchestrationCheckpointSummary): string { | ||
| 132 | return `Turn ${checkpoint.checkpointTurnCount}`; | ||
| 133 | } | ||
| 134 | |||
| 135 | function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string { | ||
| 136 | const fileCount = checkpoint.files.length; | ||
| 137 | if (checkpoint.status !== "ready") { | ||
| 138 | return `Diff ${checkpoint.status}`; | ||
| 139 | } | ||
| 140 | return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`; | ||
| 141 | } | ||
| 142 | |||
| 143 | function compareCheckpointTurnCountDescending( | ||
| 144 | left: OrchestrationCheckpointSummary, | ||
| 145 | right: OrchestrationCheckpointSummary, | ||
| 146 | ): -1 | 0 | 1 { | ||
| 147 | if (left.checkpointTurnCount === right.checkpointTurnCount) { | ||
| 148 | return 0; | ||
| 149 | } | ||
| 150 | |||
| 151 | return left.checkpointTurnCount > right.checkpointTurnCount ? -1 : 1; | ||
| 152 | } | ||
| 153 | |||
| 154 | const readyCheckpointOrder = Order.make<OrchestrationCheckpointSummary>( | ||
| 155 | compareCheckpointTurnCountDescending, | ||
| 156 | ); | ||
| 157 | |||
| 158 | function gitSubtitle(section: ReviewDiffPreviewSource): string | null { | ||
| 159 | if (section.kind === "working-tree") { | ||
| 160 | return DIRTY_WORKTREE_SUBTITLE; | ||
| 161 | } | ||
| 162 | if (section.baseRef) { | ||
| 163 | return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`; | ||
| 164 | } | ||
| 165 | return "Base branch unavailable"; | ||
| 166 | } | ||
| 167 | |||
| 168 | function stripGitPrefix(pathValue: string | undefined): string | null { | ||
| 169 | if (!pathValue) { | ||
| 170 | return null; | ||
| 171 | } | ||
| 172 | if (pathValue.startsWith("a/") || pathValue.startsWith("b/")) { | ||
| 173 | return pathValue.slice(2); | ||
| 174 | } | ||
| 175 | return pathValue; | ||
| 176 | } | ||
| 177 | |||
| 178 | function stripTrailingNewline(value: string): string { | ||
| 179 | return value.endsWith("\n") ? value.slice(0, -1) : value; | ||
| 180 | } | ||
| 181 | |||
| 182 | function splitTruncationMarker(diff: string): { | ||
| 183 | readonly text: string; | ||
| 184 | readonly truncated: boolean; | ||
| 185 | } { | ||
| 186 | const trimmed = diff.trimEnd(); | ||
| 187 | if (!trimmed.endsWith("[truncated]")) { | ||
| 188 | return { text: trimmed, truncated: false }; | ||
| 189 | } | ||
| 190 | |||
| 191 | return { | ||
| 192 | text: trimmed.replace(/\n*\[truncated\]\s*$/, "").trimEnd(), | ||
| 193 | truncated: true, | ||
| 194 | }; | ||
| 195 | } | ||
| 196 | |||
| 197 | function runDiffParserSilently<T>(callback: () => T): T { | ||
| 198 | const originalConsoleError = console.error; | ||
| 199 | console.error = () => undefined; | ||
| 200 | try { | ||
| 201 | return callback(); | ||
| 202 | } finally { | ||
| 203 | console.error = originalConsoleError; | ||
| 204 | } | ||
| 205 | } | ||
| 206 | |||
| 207 | const FNV_OFFSET_BASIS_32 = 0x811c9dc5; | ||
| 208 | const FNV_PRIME_32 = 0x01000193; | ||
| 209 | const SECONDARY_HASH_SEED = 0x9e3779b9; | ||
| 210 | const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b; | ||
| 211 | const LARGE_DIFF_LINE_THRESHOLD = 400; | ||
| 212 | const LARGE_DIFF_CHARACTER_THRESHOLD = 24_000; | ||
| 213 | const NON_TEXT_FILE_EXTENSIONS = new Set([ | ||
| 214 | "png", | ||
| 215 | "jpg", | ||
| 216 | "jpeg", | ||
| 217 | "gif", | ||
| 218 | "webp", | ||
| 219 | "bmp", | ||
| 220 | "ico", | ||
| 221 | "icns", | ||
| 222 | "avif", | ||
| 223 | "heic", | ||
| 224 | "tif", | ||
| 225 | "tiff", | ||
| 226 | "mp3", | ||
| 227 | "wav", | ||
| 228 | "flac", | ||
| 229 | "ogg", | ||
| 230 | "m4a", | ||
| 231 | "aac", | ||
| 232 | "mp4", | ||
| 233 | "mov", | ||
| 234 | "avi", | ||
| 235 | "mkv", | ||
| 236 | "webm", | ||
| 237 | "pdf", | ||
| 238 | "zip", | ||
| 239 | "gz", | ||
| 240 | "tgz", | ||
| 241 | "bz2", | ||
| 242 | "7z", | ||
| 243 | "rar", | ||
| 244 | "woff", | ||
| 245 | "woff2", | ||
| 246 | "ttf", | ||
| 247 | "otf", | ||
| 248 | "eot", | ||
| 249 | "wasm", | ||
| 250 | "exe", | ||
| 251 | "dll", | ||
| 252 | "so", | ||
| 253 | "dylib", | ||
| 254 | ]); | ||
| 255 | |||
| 256 | function fnv1a32(input: string, seed: number, multiplier: number): number { | ||
| 257 | let hash = seed >>> 0; | ||
| 258 | for (let index = 0; index < input.length; index += 1) { | ||
| 259 | hash ^= input.charCodeAt(index); | ||
| 260 | hash = Math.imul(hash, multiplier) >>> 0; | ||
| 261 | } | ||
| 262 | return hash >>> 0; | ||
| 263 | } | ||
| 264 | |||
| 265 | function buildPatchCacheKey(patch: string, scope: string): string { | ||
| 266 | const normalizedPatch = patch.trim(); | ||
| 267 | const primary = fnv1a32(normalizedPatch, FNV_OFFSET_BASIS_32, FNV_PRIME_32).toString(36); | ||
| 268 | const secondary = fnv1a32( | ||
| 269 | normalizedPatch, | ||
| 270 | SECONDARY_HASH_SEED, | ||
| 271 | SECONDARY_HASH_MULTIPLIER, | ||
| 272 | ).toString(36); | ||
| 273 | return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`; | ||
| 274 | } | ||
| 275 | |||
| 276 | function getFileExtension(path: string): string | null { | ||
| 277 | const match = /\.([a-z0-9]+)$/i.exec(path); | ||
| 278 | return match?.[1]?.toLowerCase() ?? null; | ||
| 279 | } | ||
| 280 | |||
| 281 | function countReviewRenderableLineRows(file: ReviewRenderableFile): number { | ||
| 282 | return file.rows.reduce((total, row) => total + (row.kind === "line" ? 1 : 0), 0); | ||
| 283 | } | ||
| 284 | |||
| 285 | function countReviewRenderableCharacters(file: ReviewRenderableFile): number { | ||
| 286 | return file.rows.reduce( | ||
| 287 | (total, row) => total + (row.kind === "line" ? row.content.length : row.header.length), | ||
| 288 | 0, | ||
| 289 | ); | ||
| 290 | } | ||
| 291 | |||
| 292 | export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFilePreviewState { | ||
| 293 | const extension = getFileExtension(file.path); | ||
| 294 | if (extension && NON_TEXT_FILE_EXTENSIONS.has(extension)) { | ||
| 295 | return { | ||
| 296 | kind: "suppressed", | ||
| 297 | reason: "non-text", | ||
| 298 | title: "Non-text file", | ||
| 299 | message: "Diff preview is not available for this file format.", | ||
| 300 | actionLabel: null, | ||
| 301 | }; | ||
| 302 | } | ||
| 303 | |||
| 304 | const lineCount = countReviewRenderableLineRows(file); | ||
| 305 | const characterCount = countReviewRenderableCharacters(file); | ||
| 306 | if (lineCount > LARGE_DIFF_LINE_THRESHOLD || characterCount > LARGE_DIFF_CHARACTER_THRESHOLD) { | ||
| 307 | return { | ||
| 308 | kind: "suppressed", | ||
| 309 | reason: "large", | ||
| 310 | title: "Large diff", | ||
| 311 | message: "Large diffs are not rendered by default.", | ||
| 312 | actionLabel: "Load diff", | ||
| 313 | }; | ||
| 314 | } | ||
| 315 | |||
| 316 | return { kind: "render" }; | ||
| 317 | } | ||
| 318 | |||
| 319 | // The flattened review list item model is inspired by pierre/diffs' iterator-first | ||
| 320 | // virtualization architecture, adapted here for React Native virtualization. | ||
| 321 | // Original project: https://github.com/pingdotgg/pierre/tree/main/packages/diffs | ||
| 322 | // Reference files: | ||
| 323 | // - src/utils/iterateOverDiff.ts | ||
| 324 | // - src/components/VirtualizedFileDiff.ts | ||
| 325 | export function buildReviewListItems(input: { | ||
| 326 | readonly files: ReadonlyArray<ReviewRenderableFile>; | ||
| 327 | readonly expandedFileIds: ReadonlyArray<string>; | ||
| 328 | readonly revealedLargeFileIds: ReadonlyArray<string>; | ||
| 329 | }): ReadonlyArray<ReviewListItem> { | ||
| 330 | const expandedFileIds = new Set(input.expandedFileIds); | ||
| 331 | const revealedLargeFileIds = new Set(input.revealedLargeFileIds); | ||
| 332 | const items: ReviewListItem[] = []; | ||
| 333 | |||
| 334 | input.files.forEach((file) => { | ||
| 335 | const expanded = expandedFileIds.has(file.id); | ||
| 336 | items.push({ | ||
| 337 | kind: "file-header", | ||
| 338 | id: `${file.id}:header`, | ||
| 339 | fileId: file.id, | ||
| 340 | file, | ||
| 341 | expanded, | ||
| 342 | }); | ||
| 343 | |||
| 344 | if (!expanded) { | ||
| 345 | return; | ||
| 346 | } | ||
| 347 | |||
| 348 | const previewState = getReviewFilePreviewState(file); | ||
| 349 | if (previewState.kind === "suppressed") { | ||
| 350 | if (previewState.reason !== "large" || !revealedLargeFileIds.has(file.id)) { | ||
| 351 | items.push({ | ||
| 352 | kind: "file-suppressed", | ||
| 353 | id: `${file.id}:suppressed`, | ||
| 354 | fileId: file.id, | ||
| 355 | message: previewState.message, | ||
| 356 | actionLabel: previewState.actionLabel, | ||
| 357 | }); | ||
| 358 | return; | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | let lineIndex = 0; | ||
| 363 | file.rows.forEach((row, rowIndex) => { | ||
| 364 | if (row.kind === "hunk") { | ||
| 365 | items.push({ | ||
| 366 | kind: "hunk", | ||
| 367 | id: `${file.id}:row:${rowIndex}:${row.id}`, | ||
| 368 | fileId: file.id, | ||
| 369 | file, | ||
| 370 | row, | ||
| 371 | }); | ||
| 372 | return; | ||
| 373 | } | ||
| 374 | |||
| 375 | items.push({ | ||
| 376 | kind: "line", | ||
| 377 | id: `${file.id}:row:${rowIndex}:${row.id}`, | ||
| 378 | fileId: file.id, | ||
| 379 | file, | ||
| 380 | row, | ||
| 381 | lineIndex, | ||
| 382 | }); | ||
| 383 | lineIndex += 1; | ||
| 384 | }); | ||
| 385 | }); | ||
| 386 | |||
| 387 | return items; | ||
| 388 | } | ||
| 389 | |||
| 390 | function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { | ||
| 391 | return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; | ||
| 392 | } | ||
| 393 | |||
| 394 | function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRenderableRow> { | ||
| 395 | const rows: ReviewRenderableRow[] = []; | ||
| 396 | let rowIndex = 0; | ||
| 397 | |||
| 398 | file.hunks.forEach((hunk, hunkIndex) => { | ||
| 399 | rows.push({ | ||
| 400 | kind: "hunk", | ||
| 401 | id: `${file.cacheKey ?? file.name}:hunk:${hunkIndex}`, | ||
| 402 | header: fallbackHunkHeader(hunk), | ||
| 403 | context: hunk.hunkContext ? stripTrailingNewline(hunk.hunkContext) : null, | ||
| 404 | }); | ||
| 405 | |||
| 406 | let deletionLineNumber = hunk.deletionStart; | ||
| 407 | let additionLineNumber = hunk.additionStart; | ||
| 408 | let deletionTokenIndex = hunk.deletionLineIndex; | ||
| 409 | let additionTokenIndex = hunk.additionLineIndex; | ||
| 410 | |||
| 411 | hunk.hunkContent.forEach((segment) => { | ||
| 412 | if (segment.type === "context") { | ||
| 413 | for (let index = 0; index < segment.lines; index += 1) { | ||
| 414 | rows.push({ | ||
| 415 | kind: "line", | ||
| 416 | id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`, | ||
| 417 | change: "context", | ||
| 418 | oldLineNumber: deletionLineNumber, | ||
| 419 | newLineNumber: additionLineNumber, | ||
| 420 | content: stripTrailingNewline( | ||
| 421 | file.additionLines[additionTokenIndex] ?? | ||
| 422 | file.deletionLines[deletionTokenIndex] ?? | ||
| 423 | "", | ||
| 424 | ), | ||
| 425 | additionTokenIndex, | ||
| 426 | deletionTokenIndex, | ||
| 427 | comparison: null, | ||
| 428 | }); | ||
| 429 | deletionLineNumber += 1; | ||
| 430 | additionLineNumber += 1; | ||
| 431 | deletionTokenIndex += 1; | ||
| 432 | additionTokenIndex += 1; | ||
| 433 | } | ||
| 434 | return; | ||
| 435 | } | ||
| 436 | |||
| 437 | const pairedLineCount = Math.min(segment.deletions, segment.additions); | ||
| 438 | const deletionTokenIndexStart = deletionTokenIndex; | ||
| 439 | const additionTokenIndexStart = additionTokenIndex; | ||
| 440 | |||
| 441 | for (let index = 0; index < segment.deletions; index += 1) { | ||
| 442 | rows.push({ | ||
| 443 | kind: "line", | ||
| 444 | id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`, | ||
| 445 | change: "delete", | ||
| 446 | oldLineNumber: deletionLineNumber, | ||
| 447 | newLineNumber: null, | ||
| 448 | content: stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""), | ||
| 449 | additionTokenIndex: null, | ||
| 450 | deletionTokenIndex, | ||
| 451 | comparison: | ||
| 452 | index < pairedLineCount | ||
| 453 | ? { | ||
| 454 | change: "add", | ||
| 455 | tokenIndex: additionTokenIndexStart + index, | ||
| 456 | } | ||
| 457 | : null, | ||
| 458 | }); | ||
| 459 | deletionLineNumber += 1; | ||
| 460 | deletionTokenIndex += 1; | ||
| 461 | } | ||
| 462 | |||
| 463 | for (let index = 0; index < segment.additions; index += 1) { | ||
| 464 | rows.push({ | ||
| 465 | kind: "line", | ||
| 466 | id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`, | ||
| 467 | change: "add", | ||
| 468 | oldLineNumber: null, | ||
| 469 | newLineNumber: additionLineNumber, | ||
| 470 | content: stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""), | ||
| 471 | additionTokenIndex, | ||
| 472 | deletionTokenIndex: null, | ||
| 473 | comparison: | ||
| 474 | index < pairedLineCount | ||
| 475 | ? { | ||
| 476 | change: "delete", | ||
| 477 | tokenIndex: deletionTokenIndexStart + index, | ||
| 478 | } | ||
| 479 | : null, | ||
| 480 | }); | ||
| 481 | additionLineNumber += 1; | ||
| 482 | additionTokenIndex += 1; | ||
| 483 | } | ||
| 484 | }); | ||
| 485 | }); | ||
| 486 | |||
| 487 | return rows; | ||
| 488 | } | ||
| 489 | |||
| 490 | function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile { | ||
| 491 | const path = stripGitPrefix(file.name) ?? stripGitPrefix(file.prevName) ?? file.name; | ||
| 492 | const previousPath = stripGitPrefix(file.prevName); | ||
| 493 | const additions = file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0); | ||
| 494 | const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0); | ||
| 495 | const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`; | ||
| 496 | |||
| 497 | return { | ||
| 498 | id: cacheKey, | ||
| 499 | cacheKey, | ||
| 500 | path, | ||
| 501 | previousPath, | ||
| 502 | changeType: file.type, | ||
| 503 | additions, | ||
| 504 | deletions, | ||
| 505 | languageHint: file.lang ?? null, | ||
| 506 | additionLines: file.additionLines, | ||
| 507 | deletionLines: file.deletionLines, | ||
| 508 | rows: buildRenderableRows(file), | ||
| 509 | }; | ||
| 510 | } | ||
| 511 | |||
| 512 | export function getReviewSectionIdForCheckpoint( | ||
| 513 | checkpoint: Pick<OrchestrationCheckpointSummary, "checkpointTurnCount">, | ||
| 514 | ): string { | ||
| 515 | return `turn:${checkpoint.checkpointTurnCount}`; | ||
| 516 | } | ||
| 517 | |||
| 518 | export function getReadyReviewCheckpoints( | ||
| 519 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, | ||
| 520 | ): ReadonlyArray<OrchestrationCheckpointSummary> { | ||
| 521 | return pipe( | ||
| 522 | checkpoints, | ||
| 523 | Arr.filter((checkpoint) => checkpoint.status === "ready"), | ||
| 524 | Arr.sort(readyCheckpointOrder), | ||
| 525 | ); | ||
| 526 | } | ||
| 527 | |||
| 528 | export function buildReviewSectionItems(input: { | ||
| 529 | readonly checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>; | ||
| 530 | readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>; | ||
| 531 | readonly turnDiffById: Readonly<Record<string, string | undefined>>; | ||
| 532 | readonly loadingTurnIds: Readonly<Record<string, boolean | undefined>>; | ||
| 533 | readonly loadingGitSections: boolean; | ||
| 534 | }): ReadonlyArray<ReviewSectionItem> { | ||
| 535 | const turnItems = getReadyReviewCheckpoints(input.checkpoints).map<ReviewSectionItem>( | ||
| 536 | (checkpoint) => { | ||
| 537 | const id = getReviewSectionIdForCheckpoint(checkpoint); | ||
| 538 | return { | ||
| 539 | id, | ||
| 540 | kind: "turn", | ||
| 541 | title: checkpointTitle(checkpoint), | ||
| 542 | subtitle: checkpointSubtitle(checkpoint), | ||
| 543 | diff: input.turnDiffById[id] ?? null, | ||
| 544 | isLoading: input.loadingTurnIds[id] === true, | ||
| 545 | }; | ||
| 546 | }, | ||
| 547 | ); | ||
| 548 | |||
| 549 | const gitItems = input.gitSections.map<ReviewSectionItem>((section) => ({ | ||
| 550 | id: `git:${section.kind}`, | ||
| 551 | kind: section.kind, | ||
| 552 | title: section.title, | ||
| 553 | subtitle: gitSubtitle(section), | ||
| 554 | diff: section.diff, | ||
| 555 | isLoading: false, | ||
| 556 | })); | ||
| 557 | const hasDirtyWorktreeItem = gitItems.some((item) => item.id === DIRTY_WORKTREE_SECTION_ID); | ||
| 558 | const visibleGitItems = | ||
| 559 | input.loadingGitSections && !hasDirtyWorktreeItem | ||
| 560 | ? [ | ||
| 561 | { | ||
| 562 | id: DIRTY_WORKTREE_SECTION_ID, | ||
| 563 | kind: "working-tree", | ||
| 564 | title: DIRTY_WORKTREE_TITLE, | ||
| 565 | subtitle: DIRTY_WORKTREE_SUBTITLE, | ||
| 566 | diff: null, | ||
| 567 | isLoading: true, | ||
| 568 | } satisfies ReviewSectionItem, | ||
| 569 | ...gitItems, | ||
| 570 | ] | ||
| 571 | : gitItems; | ||
| 572 | |||
| 573 | return [...turnItems, ...visibleGitItems]; | ||
| 574 | } | ||
| 575 | |||
| 576 | export function getDefaultReviewSectionId( | ||
| 577 | sections: ReadonlyArray<ReviewSectionItem>, | ||
| 578 | ): string | null { | ||
| 579 | return sections[0]?.id ?? null; | ||
| 580 | } | ||
| 581 | |||
| 582 | export function buildReviewParsedDiff( | ||
| 583 | diff: string | null | undefined, | ||
| 584 | cacheScope: string, | ||
| 585 | ): ReviewParsedDiff { | ||
| 586 | const normalized = diff?.trim(); | ||
| 587 | if (!normalized) { | ||
| 588 | return { kind: "empty" }; | ||
| 589 | } | ||
| 590 | |||
| 591 | const { text, truncated } = splitTruncationMarker(normalized); | ||
| 592 | if (text.length === 0) { | ||
| 593 | return { kind: "empty" }; | ||
| 594 | } | ||
| 595 | |||
| 596 | const notice = truncated | ||
| 597 | ? "Diff output hit the server size cap. Showing the available excerpt." | ||
| 598 | : null; | ||
| 599 | |||
| 600 | try { | ||
| 601 | const parsedPatches = runDiffParserSilently(() => | ||
| 602 | parsePatchFiles(text, buildPatchCacheKey(text, cacheScope)), | ||
| 603 | ); | ||
| 604 | const files = pipe( | ||
| 605 | parsedPatches, | ||
| 606 | Arr.flatMap((patch) => patch.files), | ||
| 607 | Arr.map(mapRenderableFile), | ||
| 608 | ); | ||
| 609 | |||
| 610 | if (files.length === 0) { | ||
| 611 | return { | ||
| 612 | kind: "raw", | ||
| 613 | text, | ||
| 614 | reason: truncated | ||
| 615 | ? "Diff was truncated before it could be parsed completely. Showing the raw excerpt." | ||
| 616 | : "Unsupported diff format. Showing raw patch.", | ||
| 617 | notice, | ||
| 618 | }; | ||
| 619 | } | ||
| 620 | |||
| 621 | return { | ||
| 622 | kind: "files", | ||
| 623 | files, | ||
| 624 | fileCount: files.length, | ||
| 625 | additions: files.reduce((total, file) => total + file.additions, 0), | ||
| 626 | deletions: files.reduce((total, file) => total + file.deletions, 0), | ||
| 627 | notice, | ||
| 628 | }; | ||
| 629 | } catch { | ||
| 630 | return { | ||
| 631 | kind: "raw", | ||
| 632 | text, | ||
| 633 | reason: truncated | ||
| 634 | ? "Diff was truncated before it could be parsed completely. Showing the raw excerpt." | ||
| 635 | : "Failed to parse patch. Showing raw patch.", | ||
| 636 | notice, | ||
| 637 | }; | ||
| 638 | } | ||
| 639 | } | ||
apps/mobile/src/features/review/reviewPerf.ts created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | interface ReviewPerformanceLike { | ||
| 2 | readonly now?: () => number; | ||
| 3 | readonly mark?: (name: string) => void; | ||
| 4 | readonly measure?: (name: string, startMark: string, endMark: string) => void; | ||
| 5 | readonly clearMarks?: (name?: string) => void; | ||
| 6 | readonly clearMeasures?: (name?: string) => void; | ||
| 7 | } | ||
| 8 | |||
| 9 | const REVIEW_PERF_PREFIX = "t3.review"; | ||
| 10 | let reviewPerfSequence = 0; | ||
| 11 | |||
| 12 | function getPerformance(): ReviewPerformanceLike | null { | ||
| 13 | const candidate = (globalThis as { readonly performance?: ReviewPerformanceLike }).performance; | ||
| 14 | return candidate ?? null; | ||
| 15 | } | ||
| 16 | |||
| 17 | export function isReviewPerfEnabled(): boolean { | ||
| 18 | return typeof __DEV__ !== "undefined" ? __DEV__ : false; | ||
| 19 | } | ||
| 20 | |||
| 21 | export function measureReviewWork<T>(name: string, callback: () => T): T { | ||
| 22 | if (!isReviewPerfEnabled()) { | ||
| 23 | return callback(); | ||
| 24 | } | ||
| 25 | |||
| 26 | const perf = getPerformance(); | ||
| 27 | const marker = `${REVIEW_PERF_PREFIX}.${name}.${reviewPerfSequence++}`; | ||
| 28 | const startMark = `${marker}.start`; | ||
| 29 | const endMark = `${marker}.end`; | ||
| 30 | const startedAt = perf?.now?.() ?? Date.now(); | ||
| 31 | |||
| 32 | perf?.mark?.(startMark); | ||
| 33 | try { | ||
| 34 | return callback(); | ||
| 35 | } finally { | ||
| 36 | const durationMs = (perf?.now?.() ?? Date.now()) - startedAt; | ||
| 37 | perf?.mark?.(endMark); | ||
| 38 | perf?.measure?.(`${REVIEW_PERF_PREFIX}.${name}`, startMark, endMark); | ||
| 39 | perf?.clearMarks?.(startMark); | ||
| 40 | perf?.clearMarks?.(endMark); | ||
| 41 | console.log(`[review-perf] ${name}`, { durationMs: Number(durationMs.toFixed(2)) }); | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | export async function measureReviewAsyncWork<T>( | ||
| 46 | name: string, | ||
| 47 | callback: () => Promise<T>, | ||
| 48 | ): Promise<T> { | ||
| 49 | if (!isReviewPerfEnabled()) { | ||
| 50 | return callback(); | ||
| 51 | } | ||
| 52 | |||
| 53 | const perf = getPerformance(); | ||
| 54 | const marker = `${REVIEW_PERF_PREFIX}.${name}.${reviewPerfSequence++}`; | ||
| 55 | const startMark = `${marker}.start`; | ||
| 56 | const endMark = `${marker}.end`; | ||
| 57 | const startedAt = perf?.now?.() ?? Date.now(); | ||
| 58 | |||
| 59 | perf?.mark?.(startMark); | ||
| 60 | try { | ||
| 61 | return await callback(); | ||
| 62 | } finally { | ||
| 63 | const durationMs = (perf?.now?.() ?? Date.now()) - startedAt; | ||
| 64 | perf?.mark?.(endMark); | ||
| 65 | perf?.measure?.(`${REVIEW_PERF_PREFIX}.${name}`, startMark, endMark); | ||
| 66 | perf?.clearMarks?.(startMark); | ||
| 67 | perf?.clearMarks?.(endMark); | ||
| 68 | console.log(`[review-perf] ${name}`, { durationMs: Number(durationMs.toFixed(2)) }); | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | export function markReviewEvent(name: string, details?: Record<string, unknown>): void { | ||
| 73 | if (!isReviewPerfEnabled()) { | ||
| 74 | return; | ||
| 75 | } | ||
| 76 | |||
| 77 | getPerformance()?.mark?.(`${REVIEW_PERF_PREFIX}.${name}`); | ||
| 78 | if (details) { | ||
| 79 | console.log(`[review-perf] ${name}`, details); | ||
| 80 | return; | ||
| 81 | } | ||
| 82 | console.log(`[review-perf] ${name}`); | ||
| 83 | } | ||
apps/mobile/src/features/review/reviewState.test.ts created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | import { assert, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | getReviewAsyncStateSnapshot, | ||
| 5 | setReviewAsyncError, | ||
| 6 | setReviewTurnDiffLoading, | ||
| 7 | } from "./reviewState"; | ||
| 8 | |||
| 9 | it("stores review async loading and error state in atoms", () => { | ||
| 10 | const threadKey = `env-local:thread-review-state-${Date.now()}`; | ||
| 11 | |||
| 12 | setReviewTurnDiffLoading(threadKey, "turn-1", true); | ||
| 13 | setReviewAsyncError(threadKey, "load failed"); | ||
| 14 | |||
| 15 | assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), { | ||
| 16 | loadingTurnIds: { "turn-1": true }, | ||
| 17 | error: "load failed", | ||
| 18 | }); | ||
| 19 | |||
| 20 | setReviewTurnDiffLoading(threadKey, "turn-1", false); | ||
| 21 | setReviewAsyncError(threadKey, null); | ||
| 22 | |||
| 23 | assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), { | ||
| 24 | loadingTurnIds: {}, | ||
| 25 | error: null, | ||
| 26 | }); | ||
| 27 | }); | ||
apps/mobile/src/features/review/reviewState.ts created+296| ... | @@ -0,0 +1,296 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | |||
| 3 | import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import { Atom } from "effect/unstable/reactivity"; | ||
| 5 | |||
| 6 | import { scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 7 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 8 | import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; | ||
| 9 | |||
| 10 | const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze<ReadonlyArray<ReviewDiffPreviewSource>>([]); | ||
| 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 | loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS, | ||
| 15 | error: null, | ||
| 16 | }); | ||
| 17 | const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze< | ||
| 18 | Readonly<Record<string, ReadonlyArray<string> | undefined>> | ||
| 19 | >({}); | ||
| 20 | const EMPTY_REVIEW_GIT_SECTIONS_ATOM = Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( | ||
| 21 | Atom.keepAlive, | ||
| 22 | Atom.withLabel("mobile:review:git-sections:null"), | ||
| 23 | ); | ||
| 24 | const EMPTY_REVIEW_TURN_DIFFS_ATOM = Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( | ||
| 25 | Atom.keepAlive, | ||
| 26 | Atom.withLabel("mobile:review:turn-diffs:null"), | ||
| 27 | ); | ||
| 28 | const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make<string | null>(null).pipe( | ||
| 29 | Atom.keepAlive, | ||
| 30 | Atom.withLabel("mobile:review:selected-section-id:null"), | ||
| 31 | ); | ||
| 32 | const EMPTY_REVIEW_ASYNC_STATE_ATOM = Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( | ||
| 33 | Atom.keepAlive, | ||
| 34 | Atom.withLabel("mobile:review:async-state:null"), | ||
| 35 | ); | ||
| 36 | const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( | ||
| 37 | Atom.keepAlive, | ||
| 38 | Atom.withLabel("mobile:review:section-file-ids:null"), | ||
| 39 | ); | ||
| 40 | |||
| 41 | const reviewGitSectionsByThreadKeyAtom = Atom.family((threadKey: string) => | ||
| 42 | Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( | ||
| 43 | Atom.keepAlive, | ||
| 44 | Atom.withLabel(`mobile:review:git-sections:${threadKey}`), | ||
| 45 | ), | ||
| 46 | ); | ||
| 47 | |||
| 48 | const reviewTurnDiffByThreadKeyAtom = Atom.family((threadKey: string) => | ||
| 49 | Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( | ||
| 50 | Atom.keepAlive, | ||
| 51 | Atom.withLabel(`mobile:review:turn-diffs:${threadKey}`), | ||
| 52 | ), | ||
| 53 | ); | ||
| 54 | |||
| 55 | const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) => | ||
| 56 | Atom.make<string | null>(null).pipe( | ||
| 57 | Atom.keepAlive, | ||
| 58 | Atom.withLabel(`mobile:review:selected-section-id:${threadKey}`), | ||
| 59 | ), | ||
| 60 | ); | ||
| 61 | |||
| 62 | const reviewAsyncStateByThreadKeyAtom = Atom.family((threadKey: string) => | ||
| 63 | Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( | ||
| 64 | Atom.keepAlive, | ||
| 65 | Atom.withLabel(`mobile:review:async-state:${threadKey}`), | ||
| 66 | ), | ||
| 67 | ); | ||
| 68 | |||
| 69 | const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => | ||
| 70 | Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( | ||
| 71 | Atom.keepAlive, | ||
| 72 | Atom.withLabel(`mobile:review:expanded-file-ids:${threadKey}`), | ||
| 73 | ), | ||
| 74 | ); | ||
| 75 | |||
| 76 | const reviewRevealedLargeFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => | ||
| 77 | Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( | ||
| 78 | Atom.keepAlive, | ||
| 79 | Atom.withLabel(`mobile:review:revealed-large-file-ids:${threadKey}`), | ||
| 80 | ), | ||
| 81 | ); | ||
| 82 | |||
| 83 | const reviewViewedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => | ||
| 84 | Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( | ||
| 85 | Atom.keepAlive, | ||
| 86 | Atom.withLabel(`mobile:review:viewed-file-ids:${threadKey}`), | ||
| 87 | ), | ||
| 88 | ); | ||
| 89 | |||
| 90 | const reviewParsedDiffBySectionCacheKeyAtom = Atom.family((cacheKey: string) => | ||
| 91 | Atom.make<{ readonly diff: string | null; readonly parsed: ReviewParsedDiff } | null>(null).pipe( | ||
| 92 | Atom.keepAlive, | ||
| 93 | Atom.withLabel(`mobile:review:parsed-diffs:${cacheKey}`), | ||
| 94 | ), | ||
| 95 | ); | ||
| 96 | |||
| 97 | export interface ReviewCacheForThread { | ||
| 98 | readonly threadKey: string | null; | ||
| 99 | readonly gitSections: ReadonlyArray<ReviewDiffPreviewSource>; | ||
| 100 | readonly turnDiffById: Readonly<Record<string, string>>; | ||
| 101 | readonly selectedSectionId: string | null; | ||
| 102 | readonly asyncState: ReviewAsyncState; | ||
| 103 | readonly expandedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>; | ||
| 104 | readonly revealedLargeFileIdsBySection: Readonly< | ||
| 105 | Record<string, ReadonlyArray<string> | undefined> | ||
| 106 | >; | ||
| 107 | readonly viewedFileIdsBySection: Readonly<Record<string, ReadonlyArray<string> | undefined>>; | ||
| 108 | } | ||
| 109 | |||
| 110 | export interface ReviewAsyncState { | ||
| 111 | readonly loadingTurnIds: Readonly<Record<string, boolean>>; | ||
| 112 | readonly error: string | null; | ||
| 113 | } | ||
| 114 | |||
| 115 | function buildThreadKey(input: { | ||
| 116 | readonly environmentId?: EnvironmentId; | ||
| 117 | readonly threadId?: ThreadId; | ||
| 118 | }): string | null { | ||
| 119 | return input.environmentId && input.threadId | ||
| 120 | ? scopedThreadKey(input.environmentId, input.threadId) | ||
| 121 | : null; | ||
| 122 | } | ||
| 123 | |||
| 124 | function buildSectionCacheKey(threadKey: string, sectionId: string): string { | ||
| 125 | return `${threadKey}:${sectionId}`; | ||
| 126 | } | ||
| 127 | |||
| 128 | export function useReviewCacheForThread(input: { | ||
| 129 | readonly environmentId?: EnvironmentId; | ||
| 130 | readonly threadId?: ThreadId; | ||
| 131 | }): ReviewCacheForThread { | ||
| 132 | const threadKey = buildThreadKey(input); | ||
| 133 | const gitSections = useAtomValue( | ||
| 134 | threadKey ? reviewGitSectionsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_GIT_SECTIONS_ATOM, | ||
| 135 | ); | ||
| 136 | const turnDiffById = useAtomValue( | ||
| 137 | threadKey ? reviewTurnDiffByThreadKeyAtom(threadKey) : EMPTY_REVIEW_TURN_DIFFS_ATOM, | ||
| 138 | ); | ||
| 139 | const selectedSectionId = useAtomValue( | ||
| 140 | threadKey | ||
| 141 | ? reviewSelectedSectionIdByThreadKeyAtom(threadKey) | ||
| 142 | : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM, | ||
| 143 | ); | ||
| 144 | const asyncState = useAtomValue( | ||
| 145 | threadKey ? reviewAsyncStateByThreadKeyAtom(threadKey) : EMPTY_REVIEW_ASYNC_STATE_ATOM, | ||
| 146 | ); | ||
| 147 | const expandedFileIdsBySection = useAtomValue( | ||
| 148 | threadKey | ||
| 149 | ? reviewExpandedFileIdsByThreadKeyAtom(threadKey) | ||
| 150 | : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, | ||
| 151 | ); | ||
| 152 | const revealedLargeFileIdsBySection = useAtomValue( | ||
| 153 | threadKey | ||
| 154 | ? reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey) | ||
| 155 | : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, | ||
| 156 | ); | ||
| 157 | const viewedFileIdsBySection = useAtomValue( | ||
| 158 | threadKey ? reviewViewedFileIdsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, | ||
| 159 | ); | ||
| 160 | |||
| 161 | return { | ||
| 162 | threadKey, | ||
| 163 | gitSections, | ||
| 164 | turnDiffById, | ||
| 165 | selectedSectionId, | ||
| 166 | asyncState, | ||
| 167 | expandedFileIdsBySection, | ||
| 168 | revealedLargeFileIdsBySection, | ||
| 169 | viewedFileIdsBySection, | ||
| 170 | }; | ||
| 171 | } | ||
| 172 | |||
| 173 | export function setReviewGitSections( | ||
| 174 | threadKey: string, | ||
| 175 | sections: ReadonlyArray<ReviewDiffPreviewSource>, | ||
| 176 | ): void { | ||
| 177 | appAtomRegistry.set(reviewGitSectionsByThreadKeyAtom(threadKey), sections); | ||
| 178 | } | ||
| 179 | |||
| 180 | export function setReviewTurnDiff(threadKey: string, sectionId: string, diff: string): void { | ||
| 181 | const atom = reviewTurnDiffByThreadKeyAtom(threadKey); | ||
| 182 | const current = appAtomRegistry.get(atom); | ||
| 183 | appAtomRegistry.set(atom, { | ||
| 184 | ...current, | ||
| 185 | [sectionId]: diff, | ||
| 186 | }); | ||
| 187 | } | ||
| 188 | |||
| 189 | export function setReviewSelectedSectionId(threadKey: string, sectionId: string | null): void { | ||
| 190 | appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId); | ||
| 191 | } | ||
| 192 | |||
| 193 | function updateReviewAsyncState( | ||
| 194 | threadKey: string, | ||
| 195 | update: (current: ReviewAsyncState) => ReviewAsyncState, | ||
| 196 | ): void { | ||
| 197 | const atom = reviewAsyncStateByThreadKeyAtom(threadKey); | ||
| 198 | appAtomRegistry.set(atom, update(appAtomRegistry.get(atom))); | ||
| 199 | } | ||
| 200 | |||
| 201 | export function setReviewTurnDiffLoading( | ||
| 202 | threadKey: string, | ||
| 203 | sectionId: string, | ||
| 204 | isLoading: boolean, | ||
| 205 | ): void { | ||
| 206 | updateReviewAsyncState(threadKey, (current) => { | ||
| 207 | const loadingTurnIds = { ...current.loadingTurnIds }; | ||
| 208 | if (isLoading) { | ||
| 209 | loadingTurnIds[sectionId] = true; | ||
| 210 | } else { | ||
| 211 | delete loadingTurnIds[sectionId]; | ||
| 212 | } | ||
| 213 | return { | ||
| 214 | ...current, | ||
| 215 | loadingTurnIds, | ||
| 216 | }; | ||
| 217 | }); | ||
| 218 | } | ||
| 219 | |||
| 220 | export function setReviewAsyncError(threadKey: string, error: string | null): void { | ||
| 221 | updateReviewAsyncState(threadKey, (current) => ({ | ||
| 222 | ...current, | ||
| 223 | error, | ||
| 224 | })); | ||
| 225 | } | ||
| 226 | |||
| 227 | export function getReviewAsyncStateSnapshot(threadKey: string): ReviewAsyncState { | ||
| 228 | return appAtomRegistry.get(reviewAsyncStateByThreadKeyAtom(threadKey)); | ||
| 229 | } | ||
| 230 | |||
| 231 | export function updateReviewExpandedFileIds( | ||
| 232 | threadKey: string, | ||
| 233 | sectionId: string, | ||
| 234 | update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined, | ||
| 235 | ): void { | ||
| 236 | const atom = reviewExpandedFileIdsByThreadKeyAtom(threadKey); | ||
| 237 | const current = appAtomRegistry.get(atom); | ||
| 238 | const nextValue = update(current[sectionId]); | ||
| 239 | appAtomRegistry.set(atom, { | ||
| 240 | ...current, | ||
| 241 | [sectionId]: nextValue, | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | |||
| 245 | export function updateReviewRevealedLargeFileIds( | ||
| 246 | threadKey: string, | ||
| 247 | sectionId: string, | ||
| 248 | update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined, | ||
| 249 | ): void { | ||
| 250 | const atom = reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey); | ||
| 251 | const current = appAtomRegistry.get(atom); | ||
| 252 | const nextValue = update(current[sectionId]); | ||
| 253 | appAtomRegistry.set(atom, { | ||
| 254 | ...current, | ||
| 255 | [sectionId]: nextValue, | ||
| 256 | }); | ||
| 257 | } | ||
| 258 | |||
| 259 | export function updateReviewViewedFileIds( | ||
| 260 | threadKey: string, | ||
| 261 | sectionId: string, | ||
| 262 | update: (current: ReadonlyArray<string> | undefined) => ReadonlyArray<string> | undefined, | ||
| 263 | ): void { | ||
| 264 | const atom = reviewViewedFileIdsByThreadKeyAtom(threadKey); | ||
| 265 | const current = appAtomRegistry.get(atom); | ||
| 266 | const nextValue = update(current[sectionId]); | ||
| 267 | appAtomRegistry.set(atom, { | ||
| 268 | ...current, | ||
| 269 | [sectionId]: nextValue, | ||
| 270 | }); | ||
| 271 | } | ||
| 272 | |||
| 273 | export function getCachedReviewParsedDiff(input: { | ||
| 274 | readonly threadKey: string | null; | ||
| 275 | readonly sectionId: string | null; | ||
| 276 | readonly diff: string | null | undefined; | ||
| 277 | }): ReviewParsedDiff { | ||
| 278 | if (!input.threadKey || !input.sectionId) { | ||
| 279 | return buildReviewParsedDiff(input.diff, input.sectionId ?? "mobile-review"); | ||
| 280 | } | ||
| 281 | |||
| 282 | const cacheKey = buildSectionCacheKey(input.threadKey, input.sectionId); | ||
| 283 | const normalizedDiff = input.diff?.trim() ?? null; | ||
| 284 | const atom = reviewParsedDiffBySectionCacheKeyAtom(cacheKey); | ||
| 285 | const cached = appAtomRegistry.get(atom); | ||
| 286 | if (cached && cached.diff === normalizedDiff) { | ||
| 287 | return cached.parsed; | ||
| 288 | } | ||
| 289 | |||
| 290 | const parsed = buildReviewParsedDiff(input.diff, input.sectionId); | ||
| 291 | appAtomRegistry.set(atom, { | ||
| 292 | diff: normalizedDiff, | ||
| 293 | parsed, | ||
| 294 | }); | ||
| 295 | return parsed; | ||
| 296 | } | ||
apps/mobile/src/features/review/reviewWordDiffs.test.ts created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; | ||
| 4 | |||
| 5 | describe("computeWordAltDiffRanges", () => { | ||
| 6 | it("joins adjacent word replacements across a single shared separator", () => { | ||
| 7 | const ranges = computeWordAltDiffRanges({ | ||
| 8 | deletionLine: "old old", | ||
| 9 | additionLine: "new new", | ||
| 10 | }); | ||
| 11 | |||
| 12 | expect(ranges.deletion).toEqual([{ start: 0, end: "old old".length }]); | ||
| 13 | expect(ranges.addition).toEqual([{ start: 0, end: "new new".length }]); | ||
| 14 | }); | ||
| 15 | |||
| 16 | it("skips inline word diffs for long lines", () => { | ||
| 17 | const longDeletion = `const before = "${"a".repeat(1_001)}";`; | ||
| 18 | const longAddition = `const after = "${"b".repeat(1_001)}";`; | ||
| 19 | |||
| 20 | const ranges = computeWordAltDiffRanges({ | ||
| 21 | deletionLine: longDeletion, | ||
| 22 | additionLine: longAddition, | ||
| 23 | }); | ||
| 24 | |||
| 25 | expect(ranges.deletion).toEqual([]); | ||
| 26 | expect(ranges.addition).toEqual([]); | ||
| 27 | }); | ||
| 28 | }); | ||
| 29 | |||
| 30 | describe("applyDiffRangesToTokens", () => { | ||
| 31 | it("splits highlighted fragments out of syntax tokens", () => { | ||
| 32 | const tokens = [ | ||
| 33 | { | ||
| 34 | content: "const before = 1;", | ||
| 35 | color: "#fff", | ||
| 36 | fontStyle: null, | ||
| 37 | }, | ||
| 38 | ]; | ||
| 39 | const nextTokens = applyDiffRangesToTokens(tokens, [{ start: 6, end: 12 }]); | ||
| 40 | |||
| 41 | expect(nextTokens).toEqual([ | ||
| 42 | { | ||
| 43 | content: "const ", | ||
| 44 | color: "#fff", | ||
| 45 | fontStyle: null, | ||
| 46 | diffHighlight: false, | ||
| 47 | }, | ||
| 48 | { | ||
| 49 | content: "before", | ||
| 50 | color: "#fff", | ||
| 51 | fontStyle: null, | ||
| 52 | diffHighlight: true, | ||
| 53 | }, | ||
| 54 | { | ||
| 55 | content: " = 1;", | ||
| 56 | color: "#fff", | ||
| 57 | fontStyle: null, | ||
| 58 | diffHighlight: false, | ||
| 59 | }, | ||
| 60 | ]); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("skips exhausted ranges when later tokens start after the last range", () => { | ||
| 64 | const tokens = [ | ||
| 65 | { | ||
| 66 | content: "a", | ||
| 67 | color: "#fff", | ||
| 68 | fontStyle: null, | ||
| 69 | }, | ||
| 70 | { | ||
| 71 | content: "b", | ||
| 72 | color: "#fff", | ||
| 73 | fontStyle: null, | ||
| 74 | }, | ||
| 75 | ]; | ||
| 76 | |||
| 77 | const nextTokens = applyDiffRangesToTokens(tokens, [{ start: 0, end: 1 }]); | ||
| 78 | |||
| 79 | expect(nextTokens).toEqual([ | ||
| 80 | { | ||
| 81 | content: "a", | ||
| 82 | color: "#fff", | ||
| 83 | fontStyle: null, | ||
| 84 | diffHighlight: true, | ||
| 85 | }, | ||
| 86 | { | ||
| 87 | content: "b", | ||
| 88 | color: "#fff", | ||
| 89 | fontStyle: null, | ||
| 90 | }, | ||
| 91 | ]); | ||
| 92 | }); | ||
| 93 | }); | ||
apps/mobile/src/features/review/reviewWordDiffs.ts created+266| ... | @@ -0,0 +1,266 @@ | ||
| 1 | import { diffWordsWithSpace } from "diff"; | ||
| 2 | |||
| 3 | import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; | ||
| 4 | |||
| 5 | interface ReviewDiffOperation { | ||
| 6 | readonly value: string; | ||
| 7 | readonly added?: true; | ||
| 8 | readonly removed?: true; | ||
| 9 | } | ||
| 10 | |||
| 11 | interface ReviewDiffHighlightRange { | ||
| 12 | readonly start: number; | ||
| 13 | readonly end: number; | ||
| 14 | } | ||
| 15 | |||
| 16 | const REVIEW_MAX_WORD_ALT_LINE_LENGTH = 1_000; | ||
| 17 | |||
| 18 | function cleanLineEnding(value: string): string { | ||
| 19 | return value.endsWith("\n") ? value.slice(0, -1) : value; | ||
| 20 | } | ||
| 21 | |||
| 22 | function pushOrJoinSpan(input: { | ||
| 23 | readonly operation: ReviewDiffOperation; | ||
| 24 | readonly spans: Array<[0 | 1, string]>; | ||
| 25 | readonly enableJoin: boolean; | ||
| 26 | readonly isNeutral?: boolean; | ||
| 27 | readonly isLastOperation?: boolean; | ||
| 28 | }): void { | ||
| 29 | const { operation, spans, enableJoin, isNeutral = false, isLastOperation = false } = input; | ||
| 30 | const lastSpan = spans.at(-1); | ||
| 31 | |||
| 32 | if (!lastSpan || isLastOperation || !enableJoin) { | ||
| 33 | spans.push([isNeutral ? 0 : 1, operation.value]); | ||
| 34 | return; | ||
| 35 | } | ||
| 36 | |||
| 37 | const lastSpanIsNeutral = lastSpan[0] === 0; | ||
| 38 | if ( | ||
| 39 | isNeutral === lastSpanIsNeutral || | ||
| 40 | (isNeutral && operation.value.length === 1 && !lastSpanIsNeutral) | ||
| 41 | ) { | ||
| 42 | lastSpan[1] += operation.value; | ||
| 43 | return; | ||
| 44 | } | ||
| 45 | |||
| 46 | spans.push([isNeutral ? 0 : 1, operation.value]); | ||
| 47 | } | ||
| 48 | |||
| 49 | function spansToRanges( | ||
| 50 | spans: ReadonlyArray<readonly [0 | 1, string]>, | ||
| 51 | ): ReadonlyArray<ReviewDiffHighlightRange> { | ||
| 52 | const ranges: ReviewDiffHighlightRange[] = []; | ||
| 53 | let offset = 0; | ||
| 54 | |||
| 55 | spans.forEach(([kind, value]) => { | ||
| 56 | const nextOffset = offset + value.length; | ||
| 57 | if (kind === 1 && value.length > 0) { | ||
| 58 | ranges.push({ start: offset, end: nextOffset }); | ||
| 59 | } | ||
| 60 | offset = nextOffset; | ||
| 61 | }); | ||
| 62 | |||
| 63 | return ranges; | ||
| 64 | } | ||
| 65 | |||
| 66 | function mergeNearbyRanges( | ||
| 67 | ranges: ReadonlyArray<ReviewDiffHighlightRange>, | ||
| 68 | ): ReadonlyArray<ReviewDiffHighlightRange> { | ||
| 69 | if (ranges.length <= 1) { | ||
| 70 | return ranges; | ||
| 71 | } | ||
| 72 | |||
| 73 | const merged: ReviewDiffHighlightRange[] = []; | ||
| 74 | |||
| 75 | ranges.forEach((range) => { | ||
| 76 | const previous = merged.at(-1); | ||
| 77 | if (previous && range.start - previous.end <= 1) { | ||
| 78 | merged[merged.length - 1] = { start: previous.start, end: range.end }; | ||
| 79 | return; | ||
| 80 | } | ||
| 81 | merged.push({ ...range }); | ||
| 82 | }); | ||
| 83 | |||
| 84 | return merged; | ||
| 85 | } | ||
| 86 | |||
| 87 | function appendTokenSegment( | ||
| 88 | target: ReviewHighlightedToken[], | ||
| 89 | source: ReviewHighlightedToken, | ||
| 90 | content: string, | ||
| 91 | diffHighlight: boolean, | ||
| 92 | ): void { | ||
| 93 | if (content.length === 0) { | ||
| 94 | return; | ||
| 95 | } | ||
| 96 | |||
| 97 | const previous = target.at(-1); | ||
| 98 | if ( | ||
| 99 | previous && | ||
| 100 | previous.color === source.color && | ||
| 101 | previous.fontStyle === source.fontStyle && | ||
| 102 | previous.diffHighlight === diffHighlight | ||
| 103 | ) { | ||
| 104 | previous.content += content; | ||
| 105 | return; | ||
| 106 | } | ||
| 107 | |||
| 108 | target.push({ | ||
| 109 | content, | ||
| 110 | color: source.color, | ||
| 111 | fontStyle: source.fontStyle, | ||
| 112 | diffHighlight, | ||
| 113 | }); | ||
| 114 | } | ||
| 115 | |||
| 116 | export function computeWordAltDiffRanges(input: { | ||
| 117 | readonly deletionLine: string; | ||
| 118 | readonly additionLine: string; | ||
| 119 | }): { | ||
| 120 | readonly deletion: ReadonlyArray<ReviewDiffHighlightRange>; | ||
| 121 | readonly addition: ReadonlyArray<ReviewDiffHighlightRange>; | ||
| 122 | } { | ||
| 123 | const deletionLine = cleanLineEnding(input.deletionLine); | ||
| 124 | const additionLine = cleanLineEnding(input.additionLine); | ||
| 125 | |||
| 126 | if (deletionLine.length === 0 && additionLine.length === 0) { | ||
| 127 | return { deletion: [], addition: [] }; | ||
| 128 | } | ||
| 129 | |||
| 130 | if ( | ||
| 131 | deletionLine.length > REVIEW_MAX_WORD_ALT_LINE_LENGTH || | ||
| 132 | additionLine.length > REVIEW_MAX_WORD_ALT_LINE_LENGTH | ||
| 133 | ) { | ||
| 134 | return { deletion: [], addition: [] }; | ||
| 135 | } | ||
| 136 | |||
| 137 | const operations = diffWordsWithSpace( | ||
| 138 | deletionLine, | ||
| 139 | additionLine, | ||
| 140 | ) as ReadonlyArray<ReviewDiffOperation>; | ||
| 141 | const deletionSpans: Array<[0 | 1, string]> = []; | ||
| 142 | const additionSpans: Array<[0 | 1, string]> = []; | ||
| 143 | const lastOperation = operations.at(-1); | ||
| 144 | |||
| 145 | operations.forEach((operation) => { | ||
| 146 | const isLastOperation = operation === lastOperation; | ||
| 147 | if (!operation.added && !operation.removed) { | ||
| 148 | pushOrJoinSpan({ | ||
| 149 | operation, | ||
| 150 | spans: deletionSpans, | ||
| 151 | enableJoin: true, | ||
| 152 | isNeutral: true, | ||
| 153 | isLastOperation, | ||
| 154 | }); | ||
| 155 | pushOrJoinSpan({ | ||
| 156 | operation, | ||
| 157 | spans: additionSpans, | ||
| 158 | enableJoin: true, | ||
| 159 | isNeutral: true, | ||
| 160 | isLastOperation, | ||
| 161 | }); | ||
| 162 | return; | ||
| 163 | } | ||
| 164 | |||
| 165 | if (operation.removed) { | ||
| 166 | pushOrJoinSpan({ | ||
| 167 | operation, | ||
| 168 | spans: deletionSpans, | ||
| 169 | enableJoin: true, | ||
| 170 | isLastOperation, | ||
| 171 | }); | ||
| 172 | return; | ||
| 173 | } | ||
| 174 | |||
| 175 | pushOrJoinSpan({ | ||
| 176 | operation, | ||
| 177 | spans: additionSpans, | ||
| 178 | enableJoin: true, | ||
| 179 | isLastOperation, | ||
| 180 | }); | ||
| 181 | }); | ||
| 182 | |||
| 183 | return { | ||
| 184 | deletion: mergeNearbyRanges(spansToRanges(deletionSpans)), | ||
| 185 | addition: mergeNearbyRanges(spansToRanges(additionSpans)), | ||
| 186 | }; | ||
| 187 | } | ||
| 188 | |||
| 189 | export function applyDiffRangesToTokens( | ||
| 190 | tokens: ReadonlyArray<ReviewHighlightedToken>, | ||
| 191 | ranges: ReadonlyArray<ReviewDiffHighlightRange>, | ||
| 192 | ): ReadonlyArray<ReviewHighlightedToken> { | ||
| 193 | if (tokens.length === 0 || ranges.length === 0) { | ||
| 194 | return tokens; | ||
| 195 | } | ||
| 196 | |||
| 197 | const nextTokens: ReviewHighlightedToken[] = []; | ||
| 198 | let tokenOffset = 0; | ||
| 199 | let rangeIndex = 0; | ||
| 200 | |||
| 201 | tokens.forEach((token) => { | ||
| 202 | const tokenStart = tokenOffset; | ||
| 203 | const tokenEnd = tokenStart + token.content.length; | ||
| 204 | tokenOffset = tokenEnd; | ||
| 205 | |||
| 206 | if (token.content.length === 0) { | ||
| 207 | nextTokens.push(token); | ||
| 208 | return; | ||
| 209 | } | ||
| 210 | |||
| 211 | while (rangeIndex < ranges.length && ranges[rangeIndex]!.end <= tokenStart) { | ||
| 212 | rangeIndex += 1; | ||
| 213 | } | ||
| 214 | |||
| 215 | if ((ranges[rangeIndex]?.start ?? Number.POSITIVE_INFINITY) >= tokenEnd) { | ||
| 216 | nextTokens.push(token.diffHighlight ? { ...token, diffHighlight: false } : token); | ||
| 217 | return; | ||
| 218 | } | ||
| 219 | |||
| 220 | let cursor = tokenStart; | ||
| 221 | let localRangeIndex = rangeIndex; | ||
| 222 | |||
| 223 | while ((ranges[localRangeIndex]?.start ?? Number.POSITIVE_INFINITY) < tokenEnd) { | ||
| 224 | const range = ranges[localRangeIndex]; | ||
| 225 | if (!range) { | ||
| 226 | break; | ||
| 227 | } | ||
| 228 | |||
| 229 | if (range.start > cursor) { | ||
| 230 | appendTokenSegment( | ||
| 231 | nextTokens, | ||
| 232 | token, | ||
| 233 | token.content.slice(cursor - tokenStart, range.start - tokenStart), | ||
| 234 | false, | ||
| 235 | ); | ||
| 236 | } | ||
| 237 | |||
| 238 | const highlightedStart = Math.max(cursor, range.start); | ||
| 239 | const highlightedEnd = Math.min(tokenEnd, range.end); | ||
| 240 | |||
| 241 | if (highlightedEnd > highlightedStart) { | ||
| 242 | appendTokenSegment( | ||
| 243 | nextTokens, | ||
| 244 | token, | ||
| 245 | token.content.slice(highlightedStart - tokenStart, highlightedEnd - tokenStart), | ||
| 246 | true, | ||
| 247 | ); | ||
| 248 | } | ||
| 249 | |||
| 250 | cursor = highlightedEnd; | ||
| 251 | if (range.end <= tokenEnd) { | ||
| 252 | localRangeIndex += 1; | ||
| 253 | } else { | ||
| 254 | break; | ||
| 255 | } | ||
| 256 | } | ||
| 257 | |||
| 258 | if (cursor < tokenEnd) { | ||
| 259 | appendTokenSegment(nextTokens, token, token.content.slice(cursor - tokenStart), false); | ||
| 260 | } | ||
| 261 | |||
| 262 | rangeIndex = localRangeIndex; | ||
| 263 | }); | ||
| 264 | |||
| 265 | return nextTokens; | ||
| 266 | } | ||
apps/mobile/src/features/review/shikiReviewHighlighter.test.ts created+121| ... | @@ -0,0 +1,121 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import type { ReviewRenderableFile } from "./reviewModel"; | ||
| 4 | import { highlightReviewFile } from "./shikiReviewHighlighter"; | ||
| 5 | |||
| 6 | function makeRenderableFile( | ||
| 7 | input: Partial<ReviewRenderableFile> & Pick<ReviewRenderableFile, "path">, | ||
| 8 | ): ReviewRenderableFile { | ||
| 9 | return { | ||
| 10 | id: input.path, | ||
| 11 | cacheKey: input.path, | ||
| 12 | previousPath: null, | ||
| 13 | changeType: "new", | ||
| 14 | additions: 0, | ||
| 15 | deletions: 0, | ||
| 16 | languageHint: null, | ||
| 17 | additionLines: [], | ||
| 18 | deletionLines: [], | ||
| 19 | rows: [], | ||
| 20 | ...input, | ||
| 21 | }; | ||
| 22 | } | ||
| 23 | |||
| 24 | describe("highlightReviewFile", () => { | ||
| 25 | it("preserves one highlighted token row per diff line even without trailing newlines", async () => { | ||
| 26 | const file = makeRenderableFile({ | ||
| 27 | path: "apps/mobile/src/example.txt", | ||
| 28 | additionLines: [ | ||
| 29 | 'const items = ["a"];', | ||
| 30 | 'expect(items).toEqual(["a"]);', | ||
| 31 | "const next = items.map((item) => item.toUpperCase());", | ||
| 32 | 'expect(next).toContain("A");', | ||
| 33 | ], | ||
| 34 | }); | ||
| 35 | |||
| 36 | const highlighted = await highlightReviewFile(file, "light"); | ||
| 37 | |||
| 38 | expect(highlighted.additionLines).toHaveLength(file.additionLines.length); | ||
| 39 | expect(highlighted.additionLines[0]?.map((token) => token.content).join("")).toBe( | ||
| 40 | file.additionLines[0], | ||
| 41 | ); | ||
| 42 | expect(highlighted.additionLines[1]?.map((token) => token.content).join("")).toBe( | ||
| 43 | file.additionLines[1], | ||
| 44 | ); | ||
| 45 | expect(highlighted.additionLines[2]?.map((token) => token.content).join("")).toBe( | ||
| 46 | file.additionLines[2], | ||
| 47 | ); | ||
| 48 | expect(highlighted.additionLines[3]?.map((token) => token.content).join("")).toBe( | ||
| 49 | file.additionLines[3], | ||
| 50 | ); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { | ||
| 54 | const file = makeRenderableFile({ | ||
| 55 | path: "apps/mobile/src/example-inline-diff.txt", | ||
| 56 | additionLines: ["const after = 2;"], | ||
| 57 | deletionLines: ["const before = 1;"], | ||
| 58 | rows: [ | ||
| 59 | { | ||
| 60 | kind: "line", | ||
| 61 | id: "delete-1", | ||
| 62 | change: "delete", | ||
| 63 | oldLineNumber: 1, | ||
| 64 | newLineNumber: null, | ||
| 65 | content: "const before = 1;", | ||
| 66 | additionTokenIndex: null, | ||
| 67 | deletionTokenIndex: 0, | ||
| 68 | comparison: { change: "add", tokenIndex: 0 }, | ||
| 69 | }, | ||
| 70 | { | ||
| 71 | kind: "line", | ||
| 72 | id: "add-1", | ||
| 73 | change: "add", | ||
| 74 | oldLineNumber: null, | ||
| 75 | newLineNumber: 1, | ||
| 76 | content: "const after = 2;", | ||
| 77 | additionTokenIndex: 0, | ||
| 78 | deletionTokenIndex: null, | ||
| 79 | comparison: { change: "delete", tokenIndex: 0 }, | ||
| 80 | }, | ||
| 81 | ], | ||
| 82 | }); | ||
| 83 | |||
| 84 | const highlighted = await highlightReviewFile(file, "light"); | ||
| 85 | |||
| 86 | expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); | ||
| 87 | expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("falls back to plain tokens for very long lines", async () => { | ||
| 91 | const longLine = `const value = "${"a".repeat(1_100)}";`; | ||
| 92 | const file = makeRenderableFile({ | ||
| 93 | path: "apps/mobile/src/example-long-line.txt", | ||
| 94 | additionLines: [longLine], | ||
| 95 | rows: [ | ||
| 96 | { | ||
| 97 | kind: "line", | ||
| 98 | id: "add-1", | ||
| 99 | change: "add", | ||
| 100 | oldLineNumber: null, | ||
| 101 | newLineNumber: 1, | ||
| 102 | content: longLine, | ||
| 103 | additionTokenIndex: 0, | ||
| 104 | deletionTokenIndex: null, | ||
| 105 | comparison: null, | ||
| 106 | }, | ||
| 107 | ], | ||
| 108 | }); | ||
| 109 | |||
| 110 | const highlighted = await highlightReviewFile(file, "light"); | ||
| 111 | |||
| 112 | expect(highlighted.additionLines).toHaveLength(1); | ||
| 113 | expect(highlighted.additionLines[0]).toEqual([ | ||
| 114 | { | ||
| 115 | content: longLine, | ||
| 116 | color: null, | ||
| 117 | fontStyle: null, | ||
| 118 | }, | ||
| 119 | ]); | ||
| 120 | }); | ||
| 121 | }); | ||
apps/mobile/src/features/review/shikiReviewHighlighter.ts created+1037| ... | @@ -0,0 +1,1037 @@ | ||
| 1 | import { createHighlighterCore, type HighlighterCore } from "@shikijs/core"; | ||
| 2 | import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript"; | ||
| 3 | import bashLanguage from "@shikijs/langs/bash"; | ||
| 4 | import javascriptLanguage from "@shikijs/langs/javascript"; | ||
| 5 | import jsonLanguage from "@shikijs/langs/json"; | ||
| 6 | import jsxLanguage from "@shikijs/langs/jsx"; | ||
| 7 | import tsxLanguage from "@shikijs/langs/tsx"; | ||
| 8 | import typescriptLanguage from "@shikijs/langs/typescript"; | ||
| 9 | import yamlLanguage from "@shikijs/langs/yaml"; | ||
| 10 | import githubDarkDefault from "@shikijs/themes/github-dark-default"; | ||
| 11 | import githubLightDefault from "@shikijs/themes/github-light-default"; | ||
| 12 | import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | resolveReviewHighlighterEngine, | ||
| 16 | resolveReviewHighlighterEnginePreference, | ||
| 17 | type ReviewHighlighterEngine, | ||
| 18 | } from "./reviewHighlighterEngine"; | ||
| 19 | import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; | ||
| 20 | import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; | ||
| 21 | |||
| 22 | export type ReviewDiffTheme = "light" | "dark"; | ||
| 23 | export type { ReviewHighlighterEngine }; | ||
| 24 | |||
| 25 | export interface ReviewHighlightedToken { | ||
| 26 | content: string; | ||
| 27 | readonly color: string | null; | ||
| 28 | readonly fontStyle: number | null; | ||
| 29 | readonly diffHighlight?: boolean; | ||
| 30 | } | ||
| 31 | |||
| 32 | export interface ReviewHighlightedFile { | ||
| 33 | readonly additionLines: ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>; | ||
| 34 | readonly deletionLines: ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>; | ||
| 35 | } | ||
| 36 | |||
| 37 | export interface ReviewHighlightFileProgress { | ||
| 38 | readonly highlightedFile: ReviewHighlightedFile; | ||
| 39 | readonly complete: boolean; | ||
| 40 | readonly highlightedLineCount: number; | ||
| 41 | } | ||
| 42 | |||
| 43 | const SHIKI_THEME_NAME_BY_SCHEME = { | ||
| 44 | light: "github-light-default", | ||
| 45 | dark: "github-dark-default", | ||
| 46 | } as const; | ||
| 47 | const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = | ||
| 48 | process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? | ||
| 49 | (process.env.NODE_ENV === "test" ? "javascript" : "native"); | ||
| 50 | const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( | ||
| 51 | REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, | ||
| 52 | ); | ||
| 53 | const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( | ||
| 54 | process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, | ||
| 55 | false, | ||
| 56 | ); | ||
| 57 | const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8; | ||
| 58 | const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8; | ||
| 59 | const REVIEW_HIGHLIGHT_CHUNK_SIZE = 200; | ||
| 60 | const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000; | ||
| 61 | const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>(); | ||
| 62 | const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>(); | ||
| 63 | const REVIEW_INITIAL_LANGUAGE_MODULES = [ | ||
| 64 | bashLanguage, | ||
| 65 | javascriptLanguage, | ||
| 66 | jsonLanguage, | ||
| 67 | jsxLanguage, | ||
| 68 | tsxLanguage, | ||
| 69 | typescriptLanguage, | ||
| 70 | yamlLanguage, | ||
| 71 | ] satisfies Parameters<typeof createHighlighterCore>[0]["langs"]; | ||
| 72 | const loadedLanguages = new Set<string>([ | ||
| 73 | "text", | ||
| 74 | "bash", | ||
| 75 | "javascript", | ||
| 76 | "json", | ||
| 77 | "jsx", | ||
| 78 | "tsx", | ||
| 79 | "typescript", | ||
| 80 | "yaml", | ||
| 81 | ]); | ||
| 82 | const languageLoadingPromises = new Map<string, Promise<boolean>>(); | ||
| 83 | const languageImports: Partial<Record<string, () => Promise<unknown>>> = { | ||
| 84 | javascript: () => import("@shikijs/langs/javascript"), | ||
| 85 | typescript: () => import("@shikijs/langs/typescript"), | ||
| 86 | jsx: () => import("@shikijs/langs/jsx"), | ||
| 87 | tsx: () => import("@shikijs/langs/tsx"), | ||
| 88 | python: () => import("@shikijs/langs/python"), | ||
| 89 | rust: () => import("@shikijs/langs/rust"), | ||
| 90 | go: () => import("@shikijs/langs/go"), | ||
| 91 | java: () => import("@shikijs/langs/java"), | ||
| 92 | kotlin: () => import("@shikijs/langs/kotlin"), | ||
| 93 | swift: () => import("@shikijs/langs/swift"), | ||
| 94 | "objective-c": () => import("@shikijs/langs/objective-c"), | ||
| 95 | c: () => import("@shikijs/langs/c"), | ||
| 96 | cpp: () => import("@shikijs/langs/cpp"), | ||
| 97 | csharp: () => import("@shikijs/langs/csharp"), | ||
| 98 | php: () => import("@shikijs/langs/php"), | ||
| 99 | ruby: () => import("@shikijs/langs/ruby"), | ||
| 100 | lua: () => import("@shikijs/langs/lua"), | ||
| 101 | perl: () => import("@shikijs/langs/perl"), | ||
| 102 | r: () => import("@shikijs/langs/r"), | ||
| 103 | dart: () => import("@shikijs/langs/dart"), | ||
| 104 | scala: () => import("@shikijs/langs/scala"), | ||
| 105 | elixir: () => import("@shikijs/langs/elixir"), | ||
| 106 | haskell: () => import("@shikijs/langs/haskell"), | ||
| 107 | clojure: () => import("@shikijs/langs/clojure"), | ||
| 108 | ocaml: () => import("@shikijs/langs/ocaml"), | ||
| 109 | fsharp: () => import("@shikijs/langs/fsharp"), | ||
| 110 | erlang: () => import("@shikijs/langs/erlang"), | ||
| 111 | zig: () => import("@shikijs/langs/zig"), | ||
| 112 | nim: () => import("@shikijs/langs/nim"), | ||
| 113 | html: () => import("@shikijs/langs/html"), | ||
| 114 | css: () => import("@shikijs/langs/css"), | ||
| 115 | scss: () => import("@shikijs/langs/scss"), | ||
| 116 | less: () => import("@shikijs/langs/less"), | ||
| 117 | xml: () => import("@shikijs/langs/xml"), | ||
| 118 | svg: () => import("@shikijs/langs/xml"), | ||
| 119 | vue: () => import("@shikijs/langs/vue"), | ||
| 120 | svelte: () => import("@shikijs/langs/svelte"), | ||
| 121 | astro: () => import("@shikijs/langs/astro"), | ||
| 122 | json: () => import("@shikijs/langs/json"), | ||
| 123 | jsonc: () => import("@shikijs/langs/jsonc"), | ||
| 124 | yaml: () => import("@shikijs/langs/yaml"), | ||
| 125 | toml: () => import("@shikijs/langs/toml"), | ||
| 126 | ini: () => import("@shikijs/langs/ini"), | ||
| 127 | bash: () => import("@shikijs/langs/bash"), | ||
| 128 | shellscript: () => import("@shikijs/langs/shellscript"), | ||
| 129 | powershell: () => import("@shikijs/langs/powershell"), | ||
| 130 | fish: () => import("@shikijs/langs/fish"), | ||
| 131 | sql: () => import("@shikijs/langs/sql"), | ||
| 132 | graphql: () => import("@shikijs/langs/graphql"), | ||
| 133 | prisma: () => import("@shikijs/langs/prisma"), | ||
| 134 | docker: () => import("@shikijs/langs/docker"), | ||
| 135 | hcl: () => import("@shikijs/langs/hcl"), | ||
| 136 | nix: () => import("@shikijs/langs/nix"), | ||
| 137 | markdown: () => import("@shikijs/langs/markdown"), | ||
| 138 | mdx: () => import("@shikijs/langs/mdx"), | ||
| 139 | tex: () => import("@shikijs/langs/tex"), | ||
| 140 | diff: () => import("@shikijs/langs/diff"), | ||
| 141 | regex: () => import("@shikijs/langs/regex"), | ||
| 142 | viml: () => import("@shikijs/langs/viml"), | ||
| 143 | makefile: () => import("@shikijs/langs/makefile"), | ||
| 144 | cmake: () => import("@shikijs/langs/cmake"), | ||
| 145 | groovy: () => import("@shikijs/langs/groovy"), | ||
| 146 | }; | ||
| 147 | |||
| 148 | const languageAliases: Record<string, string> = { | ||
| 149 | js: "javascript", | ||
| 150 | mjs: "javascript", | ||
| 151 | cjs: "javascript", | ||
| 152 | ts: "typescript", | ||
| 153 | mts: "typescript", | ||
| 154 | cts: "typescript", | ||
| 155 | py: "python", | ||
| 156 | rb: "ruby", | ||
| 157 | rs: "rust", | ||
| 158 | sh: "bash", | ||
| 159 | zsh: "bash", | ||
| 160 | shell: "shellscript", | ||
| 161 | yml: "yaml", | ||
| 162 | md: "markdown", | ||
| 163 | "c++": "cpp", | ||
| 164 | "c#": "csharp", | ||
| 165 | cs: "csharp", | ||
| 166 | dockerfile: "docker", | ||
| 167 | vim: "viml", | ||
| 168 | objc: "objective-c", | ||
| 169 | objectivec: "objective-c", | ||
| 170 | "obj-c": "objective-c", | ||
| 171 | ps1: "powershell", | ||
| 172 | pwsh: "powershell", | ||
| 173 | hs: "haskell", | ||
| 174 | ex: "elixir", | ||
| 175 | exs: "elixir", | ||
| 176 | erl: "erlang", | ||
| 177 | clj: "clojure", | ||
| 178 | ml: "ocaml", | ||
| 179 | fs: "fsharp", | ||
| 180 | tf: "hcl", | ||
| 181 | make: "makefile", | ||
| 182 | plain: "text", | ||
| 183 | plaintext: "text", | ||
| 184 | txt: "text", | ||
| 185 | }; | ||
| 186 | let highlighterPromise: Promise<HighlighterCore> | null = null; | ||
| 187 | let activeHighlighterEnginePromise: Promise<ReviewHighlighterEngine> | null = null; | ||
| 188 | |||
| 189 | type LoadedLanguageModule = { | ||
| 190 | default: Parameters<HighlighterCore["loadLanguage"]>[0]; | ||
| 191 | }; | ||
| 192 | |||
| 193 | function resolveReviewHighlighterBooleanFlag( | ||
| 194 | value: string | undefined, | ||
| 195 | defaultValue: boolean, | ||
| 196 | ): boolean { | ||
| 197 | switch (value) { | ||
| 198 | case "1": | ||
| 199 | case "true": | ||
| 200 | return true; | ||
| 201 | case "0": | ||
| 202 | case "false": | ||
| 203 | return false; | ||
| 204 | default: | ||
| 205 | return defaultValue; | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | function isReviewHighlighterDebugLoggingEnabled(): boolean { | ||
| 210 | return typeof __DEV__ !== "undefined" ? __DEV__ : false; | ||
| 211 | } | ||
| 212 | |||
| 213 | function logReviewHighlighterDiagnostic(message: string, details?: Record<string, unknown>): void { | ||
| 214 | if (!isReviewHighlighterDebugLoggingEnabled()) { | ||
| 215 | return; | ||
| 216 | } | ||
| 217 | |||
| 218 | if (details) { | ||
| 219 | console.log(`[review-highlighter] ${message}`, details); | ||
| 220 | return; | ||
| 221 | } | ||
| 222 | |||
| 223 | console.log(`[review-highlighter] ${message}`); | ||
| 224 | } | ||
| 225 | |||
| 226 | function logReviewHighlighterDiagnosticError(message: string, error: unknown): void { | ||
| 227 | if (!isReviewHighlighterDebugLoggingEnabled()) { | ||
| 228 | return; | ||
| 229 | } | ||
| 230 | |||
| 231 | if (error instanceof Error) { | ||
| 232 | console.error(`[review-highlighter] ${message}`, { | ||
| 233 | name: error.name, | ||
| 234 | message: error.message, | ||
| 235 | stack: error.stack, | ||
| 236 | }); | ||
| 237 | return; | ||
| 238 | } | ||
| 239 | |||
| 240 | console.error(`[review-highlighter] ${message}`, error); | ||
| 241 | } | ||
| 242 | |||
| 243 | function stripTrailingNewline(value: string): string { | ||
| 244 | return value.endsWith("\n") ? value.slice(0, -1) : value; | ||
| 245 | } | ||
| 246 | |||
| 247 | function joinPatchLines(lines: ReadonlyArray<string>): string { | ||
| 248 | return lines.map(stripTrailingNewline).join("\n"); | ||
| 249 | } | ||
| 250 | |||
| 251 | function waitForNextFrame(): Promise<void> { | ||
| 252 | return new Promise((resolve) => { | ||
| 253 | setTimeout(resolve, 0); | ||
| 254 | }); | ||
| 255 | } | ||
| 256 | |||
| 257 | async function getHighlighter(): Promise<HighlighterCore> { | ||
| 258 | if (!highlighterPromise) { | ||
| 259 | const configuredHighlighterPromise = (async () => { | ||
| 260 | let nativeEngineAvailable = false; | ||
| 261 | |||
| 262 | logReviewHighlighterDiagnostic("initializing", { | ||
| 263 | configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, | ||
| 264 | preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, | ||
| 265 | resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, | ||
| 266 | }); | ||
| 267 | |||
| 268 | const themes = [githubLightDefault, githubDarkDefault]; | ||
| 269 | |||
| 270 | if (REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE !== "javascript") { | ||
| 271 | try { | ||
| 272 | const nativeEngineModule = await import("react-native-shiki-engine"); | ||
| 273 | nativeEngineAvailable = nativeEngineModule.isNativeEngineAvailable(); | ||
| 274 | logReviewHighlighterDiagnostic("checked native engine availability", { | ||
| 275 | nativeEngineAvailable, | ||
| 276 | }); | ||
| 277 | |||
| 278 | if (nativeEngineAvailable) { | ||
| 279 | logReviewHighlighterDiagnostic("creating native regex engine"); | ||
| 280 | const highlighter = await createHighlighterCore({ | ||
| 281 | themes, | ||
| 282 | langs: REVIEW_INITIAL_LANGUAGE_MODULES, | ||
| 283 | engine: nativeEngineModule.createNativeEngine(), | ||
| 284 | }); | ||
| 285 | logReviewHighlighterDiagnostic("using native engine"); | ||
| 286 | return { | ||
| 287 | highlighter, | ||
| 288 | engine: "native" as const, | ||
| 289 | }; | ||
| 290 | } | ||
| 291 | } catch (error) { | ||
| 292 | logReviewHighlighterDiagnosticError( | ||
| 293 | "native engine initialization failed; falling back to javascript", | ||
| 294 | error, | ||
| 295 | ); | ||
| 296 | nativeEngineAvailable = false; | ||
| 297 | } | ||
| 298 | } else { | ||
| 299 | logReviewHighlighterDiagnostic("skipping native engine probe", { | ||
| 300 | reason: "preference-forced-javascript", | ||
| 301 | }); | ||
| 302 | } | ||
| 303 | |||
| 304 | const engine = resolveReviewHighlighterEngine( | ||
| 305 | REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, | ||
| 306 | nativeEngineAvailable, | ||
| 307 | ); | ||
| 308 | const highlighter = await createHighlighterCore({ | ||
| 309 | themes, | ||
| 310 | langs: REVIEW_INITIAL_LANGUAGE_MODULES, | ||
| 311 | engine: createJavaScriptRegexEngine(), | ||
| 312 | }); | ||
| 313 | logReviewHighlighterDiagnostic("using javascript engine", { | ||
| 314 | resolvedEngine: engine, | ||
| 315 | }); | ||
| 316 | return { | ||
| 317 | highlighter, | ||
| 318 | engine, | ||
| 319 | }; | ||
| 320 | })(); | ||
| 321 | |||
| 322 | highlighterPromise = configuredHighlighterPromise | ||
| 323 | .then((result) => result.highlighter) | ||
| 324 | .catch((error) => { | ||
| 325 | highlighterPromise = null; | ||
| 326 | activeHighlighterEnginePromise = null; | ||
| 327 | throw error; | ||
| 328 | }); | ||
| 329 | activeHighlighterEnginePromise = configuredHighlighterPromise | ||
| 330 | .then((result) => result.engine) | ||
| 331 | .catch((error) => { | ||
| 332 | activeHighlighterEnginePromise = null; | ||
| 333 | throw error; | ||
| 334 | }); | ||
| 335 | } | ||
| 336 | |||
| 337 | return highlighterPromise; | ||
| 338 | } | ||
| 339 | |||
| 340 | export async function getActiveReviewHighlighterEngine(): Promise<ReviewHighlighterEngine> { | ||
| 341 | await getHighlighter(); | ||
| 342 | return activeHighlighterEnginePromise ?? Promise.resolve("javascript"); | ||
| 343 | } | ||
| 344 | |||
| 345 | export async function prepareReviewHighlighter(): Promise<void> { | ||
| 346 | await getHighlighter(); | ||
| 347 | } | ||
| 348 | |||
| 349 | export async function prepareReviewHighlighterLanguages( | ||
| 350 | languages: ReadonlyArray<string>, | ||
| 351 | ): Promise<void> { | ||
| 352 | const highlighter = await getHighlighter(); | ||
| 353 | await Promise.all( | ||
| 354 | languages.map(async (language) => { | ||
| 355 | const candidate = resolveLanguageAlias(language); | ||
| 356 | if (candidate === "text" || !(candidate in languageImports)) { | ||
| 357 | return; | ||
| 358 | } | ||
| 359 | |||
| 360 | await loadSingleLanguage(highlighter, candidate); | ||
| 361 | }), | ||
| 362 | ); | ||
| 363 | } | ||
| 364 | |||
| 365 | function resolveLanguageAlias(language: string): string { | ||
| 366 | const normalized = language.toLowerCase(); | ||
| 367 | return languageAliases[normalized] ?? normalized; | ||
| 368 | } | ||
| 369 | |||
| 370 | function resolveLoadedLanguageFromPath( | ||
| 371 | path: string, | ||
| 372 | languageHint: string | null = null, | ||
| 373 | ): string | null { | ||
| 374 | const detectedLanguage = languageHint ?? getFiletypeFromFileName(path); | ||
| 375 | if (!detectedLanguage) { | ||
| 376 | return "text"; | ||
| 377 | } | ||
| 378 | |||
| 379 | const candidate = resolveLanguageAlias(detectedLanguage); | ||
| 380 | if (candidate === "text" || candidate === "ansi") { | ||
| 381 | return "text"; | ||
| 382 | } | ||
| 383 | |||
| 384 | if (!(candidate in languageImports)) { | ||
| 385 | return "text"; | ||
| 386 | } | ||
| 387 | |||
| 388 | return loadedLanguages.has(candidate) ? candidate : null; | ||
| 389 | } | ||
| 390 | |||
| 391 | async function loadSingleLanguage( | ||
| 392 | highlighter: HighlighterCore, | ||
| 393 | language: string, | ||
| 394 | ): Promise<boolean> { | ||
| 395 | if (loadedLanguages.has(language)) { | ||
| 396 | return true; | ||
| 397 | } | ||
| 398 | |||
| 399 | const existingPromise = languageLoadingPromises.get(language); | ||
| 400 | if (existingPromise) { | ||
| 401 | return existingPromise; | ||
| 402 | } | ||
| 403 | |||
| 404 | const importer = languageImports[language]; | ||
| 405 | if (!importer) { | ||
| 406 | return false; | ||
| 407 | } | ||
| 408 | |||
| 409 | const loadingPromise = (async () => { | ||
| 410 | try { | ||
| 411 | const languageModule = (await importer()) as LoadedLanguageModule; | ||
| 412 | await highlighter.loadLanguage(languageModule.default); | ||
| 413 | loadedLanguages.add(language); | ||
| 414 | return true; | ||
| 415 | } catch { | ||
| 416 | return false; | ||
| 417 | } finally { | ||
| 418 | languageLoadingPromises.delete(language); | ||
| 419 | } | ||
| 420 | })(); | ||
| 421 | |||
| 422 | languageLoadingPromises.set(language, loadingPromise); | ||
| 423 | return loadingPromise; | ||
| 424 | } | ||
| 425 | |||
| 426 | async function resolveLanguageFromPath( | ||
| 427 | path: string, | ||
| 428 | languageHint: string | null = null, | ||
| 429 | ): Promise<string> { | ||
| 430 | const loadedLanguage = resolveLoadedLanguageFromPath(path, languageHint); | ||
| 431 | if (loadedLanguage) { | ||
| 432 | return loadedLanguage; | ||
| 433 | } | ||
| 434 | |||
| 435 | const detectedLanguage = languageHint ?? getFiletypeFromFileName(path); | ||
| 436 | if (!detectedLanguage) { | ||
| 437 | return "text"; | ||
| 438 | } | ||
| 439 | |||
| 440 | const candidate = resolveLanguageAlias(detectedLanguage); | ||
| 441 | if (candidate === "text" || candidate === "ansi") { | ||
| 442 | return "text"; | ||
| 443 | } | ||
| 444 | |||
| 445 | if (!(candidate in languageImports)) { | ||
| 446 | return "text"; | ||
| 447 | } | ||
| 448 | |||
| 449 | if (loadedLanguages.has(candidate)) { | ||
| 450 | return candidate; | ||
| 451 | } | ||
| 452 | |||
| 453 | const highlighter = await getHighlighter(); | ||
| 454 | const loaded = await loadSingleLanguage(highlighter, candidate); | ||
| 455 | if (!loaded) { | ||
| 456 | return "text"; | ||
| 457 | } | ||
| 458 | |||
| 459 | return candidate; | ||
| 460 | } | ||
| 461 | |||
| 462 | async function resolveLanguage(file: ReviewRenderableFile): Promise<string> { | ||
| 463 | return ( | ||
| 464 | resolveLoadedLanguageFromPath(file.path, file.languageHint) ?? | ||
| 465 | (await resolveLanguageFromPath(file.path, file.languageHint)) | ||
| 466 | ); | ||
| 467 | } | ||
| 468 | |||
| 469 | function normalizeHighlightedLines( | ||
| 470 | tokenLines: ReadonlyArray<ReadonlyArray<{ content: string; color?: string; fontStyle?: number }>>, | ||
| 471 | ): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> { | ||
| 472 | return tokenLines.map((line) => | ||
| 473 | line.map((token) => ({ | ||
| 474 | content: token.content, | ||
| 475 | color: token.color ?? null, | ||
| 476 | fontStyle: token.fontStyle ?? null, | ||
| 477 | })), | ||
| 478 | ); | ||
| 479 | } | ||
| 480 | |||
| 481 | function makePlainHighlightedLines( | ||
| 482 | lines: ReadonlyArray<string>, | ||
| 483 | ): ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>> { | ||
| 484 | return lines.map((line) => [ | ||
| 485 | { | ||
| 486 | content: stripTrailingNewline(line), | ||
| 487 | color: null, | ||
| 488 | fontStyle: null, | ||
| 489 | }, | ||
| 490 | ]); | ||
| 491 | } | ||
| 492 | |||
| 493 | function applyWordAltDiffHighlightsToFile( | ||
| 494 | file: ReviewRenderableFile, | ||
| 495 | highlighted: ReviewHighlightedFile, | ||
| 496 | ): ReviewHighlightedFile { | ||
| 497 | const nextAdditionLines = [...highlighted.additionLines]; | ||
| 498 | const nextDeletionLines = [...highlighted.deletionLines]; | ||
| 499 | const processedPairs = new Set<string>(); | ||
| 500 | let changed = false; | ||
| 501 | |||
| 502 | file.rows.forEach((row) => { | ||
| 503 | if (row.kind !== "line" || row.change === "context" || !row.comparison) { | ||
| 504 | return; | ||
| 505 | } | ||
| 506 | |||
| 507 | const deletionTokenIndex = | ||
| 508 | row.change === "delete" | ||
| 509 | ? row.deletionTokenIndex | ||
| 510 | : row.comparison.change === "delete" | ||
| 511 | ? row.comparison.tokenIndex | ||
| 512 | : null; | ||
| 513 | const additionTokenIndex = | ||
| 514 | row.change === "add" | ||
| 515 | ? row.additionTokenIndex | ||
| 516 | : row.comparison.change === "add" | ||
| 517 | ? row.comparison.tokenIndex | ||
| 518 | : null; | ||
| 519 | |||
| 520 | if (deletionTokenIndex === null || additionTokenIndex === null) { | ||
| 521 | return; | ||
| 522 | } | ||
| 523 | |||
| 524 | const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`; | ||
| 525 | if (processedPairs.has(pairKey)) { | ||
| 526 | return; | ||
| 527 | } | ||
| 528 | processedPairs.add(pairKey); | ||
| 529 | |||
| 530 | const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""); | ||
| 531 | const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""); | ||
| 532 | const ranges = computeWordAltDiffRanges({ deletionLine, additionLine }); | ||
| 533 | |||
| 534 | if (ranges.deletion.length > 0) { | ||
| 535 | nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens( | ||
| 536 | nextDeletionLines[deletionTokenIndex] ?? [], | ||
| 537 | ranges.deletion, | ||
| 538 | ); | ||
| 539 | changed = true; | ||
| 540 | } | ||
| 541 | |||
| 542 | if (ranges.addition.length > 0) { | ||
| 543 | nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens( | ||
| 544 | nextAdditionLines[additionTokenIndex] ?? [], | ||
| 545 | ranges.addition, | ||
| 546 | ); | ||
| 547 | changed = true; | ||
| 548 | } | ||
| 549 | }); | ||
| 550 | |||
| 551 | return changed | ||
| 552 | ? { | ||
| 553 | additionLines: nextAdditionLines, | ||
| 554 | deletionLines: nextDeletionLines, | ||
| 555 | } | ||
| 556 | : highlighted; | ||
| 557 | } | ||
| 558 | |||
| 559 | function applyWordAltDiffHighlightsToSelectedLines(input: { | ||
| 560 | readonly lines: ReadonlyArray<ReviewRenderableLineRow>; | ||
| 561 | readonly tokenMap: Record<string, ReadonlyArray<ReviewHighlightedToken>>; | ||
| 562 | }): Record<string, ReadonlyArray<ReviewHighlightedToken>> { | ||
| 563 | const additionLineByTokenIndex = new Map<number, ReviewRenderableLineRow>(); | ||
| 564 | const deletionLineByTokenIndex = new Map<number, ReviewRenderableLineRow>(); | ||
| 565 | |||
| 566 | input.lines.forEach((line) => { | ||
| 567 | if (line.change === "add" && line.additionTokenIndex !== null) { | ||
| 568 | additionLineByTokenIndex.set(line.additionTokenIndex, line); | ||
| 569 | } | ||
| 570 | if (line.change === "delete" && line.deletionTokenIndex !== null) { | ||
| 571 | deletionLineByTokenIndex.set(line.deletionTokenIndex, line); | ||
| 572 | } | ||
| 573 | }); | ||
| 574 | |||
| 575 | const nextTokenMap = { ...input.tokenMap }; | ||
| 576 | const processedPairs = new Set<string>(); | ||
| 577 | |||
| 578 | input.lines.forEach((line) => { | ||
| 579 | if (line.change === "context" || !line.comparison) { | ||
| 580 | return; | ||
| 581 | } | ||
| 582 | |||
| 583 | const pairedDeletionLine = | ||
| 584 | line.change === "delete" | ||
| 585 | ? line | ||
| 586 | : line.comparison.change === "delete" | ||
| 587 | ? deletionLineByTokenIndex.get(line.comparison.tokenIndex) | ||
| 588 | : undefined; | ||
| 589 | const pairedAdditionLine = | ||
| 590 | line.change === "add" | ||
| 591 | ? line | ||
| 592 | : line.comparison.change === "add" | ||
| 593 | ? additionLineByTokenIndex.get(line.comparison.tokenIndex) | ||
| 594 | : undefined; | ||
| 595 | |||
| 596 | if ( | ||
| 597 | !pairedDeletionLine || | ||
| 598 | !pairedAdditionLine || | ||
| 599 | pairedDeletionLine.deletionTokenIndex === null || | ||
| 600 | pairedAdditionLine.additionTokenIndex === null | ||
| 601 | ) { | ||
| 602 | return; | ||
| 603 | } | ||
| 604 | |||
| 605 | const pairKey = `${pairedDeletionLine.deletionTokenIndex}:${pairedAdditionLine.additionTokenIndex}`; | ||
| 606 | if (processedPairs.has(pairKey)) { | ||
| 607 | return; | ||
| 608 | } | ||
| 609 | processedPairs.add(pairKey); | ||
| 610 | |||
| 611 | const ranges = computeWordAltDiffRanges({ | ||
| 612 | deletionLine: pairedDeletionLine.content, | ||
| 613 | additionLine: pairedAdditionLine.content, | ||
| 614 | }); | ||
| 615 | |||
| 616 | if (ranges.deletion.length > 0) { | ||
| 617 | nextTokenMap[pairedDeletionLine.id] = applyDiffRangesToTokens( | ||
| 618 | nextTokenMap[pairedDeletionLine.id] ?? [], | ||
| 619 | ranges.deletion, | ||
| 620 | ); | ||
| 621 | } | ||
| 622 | if (ranges.addition.length > 0) { | ||
| 623 | nextTokenMap[pairedAdditionLine.id] = applyDiffRangesToTokens( | ||
| 624 | nextTokenMap[pairedAdditionLine.id] ?? [], | ||
| 625 | ranges.addition, | ||
| 626 | ); | ||
| 627 | } | ||
| 628 | }); | ||
| 629 | |||
| 630 | return nextTokenMap; | ||
| 631 | } | ||
| 632 | |||
| 633 | async function highlightLines( | ||
| 634 | code: string, | ||
| 635 | language: string, | ||
| 636 | theme: string, | ||
| 637 | ): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> { | ||
| 638 | if (code.length === 0) { | ||
| 639 | return []; | ||
| 640 | } | ||
| 641 | |||
| 642 | const highlighter = await getHighlighter(); | ||
| 643 | const sourceLines = code.split("\n"); | ||
| 644 | const highlightedLines: Array<ReadonlyArray<ReviewHighlightedToken>> = []; | ||
| 645 | const shortLineBatch: string[] = []; | ||
| 646 | |||
| 647 | const flushShortLineBatch = async (): Promise<void> => { | ||
| 648 | if (shortLineBatch.length === 0) { | ||
| 649 | return; | ||
| 650 | } | ||
| 651 | |||
| 652 | const tokenLines = highlighter.codeToTokensBase(shortLineBatch.join("\n"), { | ||
| 653 | lang: language, | ||
| 654 | theme, | ||
| 655 | }); | ||
| 656 | highlightedLines.push(...normalizeHighlightedLines(tokenLines)); | ||
| 657 | shortLineBatch.length = 0; | ||
| 658 | }; | ||
| 659 | |||
| 660 | for (let lineIndex = 0; lineIndex < sourceLines.length; lineIndex += 1) { | ||
| 661 | const line = sourceLines[lineIndex] ?? ""; | ||
| 662 | |||
| 663 | if (line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { | ||
| 664 | await flushShortLineBatch(); | ||
| 665 | highlightedLines.push([{ content: line, color: null, fontStyle: null }]); | ||
| 666 | } else { | ||
| 667 | shortLineBatch.push(line); | ||
| 668 | } | ||
| 669 | |||
| 670 | if (shortLineBatch.length >= REVIEW_HIGHLIGHT_CHUNK_SIZE) { | ||
| 671 | await flushShortLineBatch(); | ||
| 672 | } | ||
| 673 | |||
| 674 | if ( | ||
| 675 | sourceLines.length > REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD && | ||
| 676 | lineIndex + 1 < sourceLines.length && | ||
| 677 | (shortLineBatch.length === 0 || line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) | ||
| 678 | ) { | ||
| 679 | await waitForNextFrame(); | ||
| 680 | } | ||
| 681 | } | ||
| 682 | |||
| 683 | await flushShortLineBatch(); | ||
| 684 | |||
| 685 | return highlightedLines; | ||
| 686 | } | ||
| 687 | |||
| 688 | async function highlightPatchLinesInChunks(input: { | ||
| 689 | readonly lines: ReadonlyArray<string>; | ||
| 690 | readonly language: string; | ||
| 691 | readonly theme: string; | ||
| 692 | readonly onChunk: ( | ||
| 693 | startIndex: number, | ||
| 694 | tokens: ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>, | ||
| 695 | ) => void; | ||
| 696 | }): Promise<ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>> { | ||
| 697 | if (input.lines.length === 0) { | ||
| 698 | return []; | ||
| 699 | } | ||
| 700 | |||
| 701 | const highlighter = await getHighlighter(); | ||
| 702 | const highlightedLines: Array<ReadonlyArray<ReviewHighlightedToken>> = []; | ||
| 703 | |||
| 704 | for ( | ||
| 705 | let startIndex = 0; | ||
| 706 | startIndex < input.lines.length; | ||
| 707 | startIndex += REVIEW_HIGHLIGHT_CHUNK_SIZE | ||
| 708 | ) { | ||
| 709 | const lineChunk = input.lines.slice(startIndex, startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE); | ||
| 710 | const chunkTokens: Array<ReadonlyArray<ReviewHighlightedToken>> = []; | ||
| 711 | const tokenizableLines: string[] = []; | ||
| 712 | const tokenizableIndexes: number[] = []; | ||
| 713 | |||
| 714 | lineChunk.forEach((line, index) => { | ||
| 715 | const strippedLine = stripTrailingNewline(line); | ||
| 716 | if (strippedLine.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { | ||
| 717 | chunkTokens[index] = [{ content: strippedLine, color: null, fontStyle: null }]; | ||
| 718 | return; | ||
| 719 | } | ||
| 720 | |||
| 721 | tokenizableIndexes.push(index); | ||
| 722 | tokenizableLines.push(strippedLine); | ||
| 723 | }); | ||
| 724 | |||
| 725 | if (tokenizableLines.length > 0) { | ||
| 726 | const tokenLines = highlighter.codeToTokensBase(tokenizableLines.join("\n"), { | ||
| 727 | lang: input.language, | ||
| 728 | theme: input.theme, | ||
| 729 | }); | ||
| 730 | const normalizedTokenLines = normalizeHighlightedLines(tokenLines); | ||
| 731 | |||
| 732 | tokenizableIndexes.forEach((chunkIndex, tokenIndex) => { | ||
| 733 | chunkTokens[chunkIndex] = normalizedTokenLines[tokenIndex] ?? []; | ||
| 734 | }); | ||
| 735 | } | ||
| 736 | |||
| 737 | const completedChunk = lineChunk.map((_, index) => chunkTokens[index] ?? []); | ||
| 738 | highlightedLines.push(...completedChunk); | ||
| 739 | input.onChunk(startIndex, completedChunk); | ||
| 740 | |||
| 741 | if (startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE < input.lines.length) { | ||
| 742 | await waitForNextFrame(); | ||
| 743 | } | ||
| 744 | } | ||
| 745 | |||
| 746 | return highlightedLines; | ||
| 747 | } | ||
| 748 | |||
| 749 | function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { | ||
| 750 | return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; | ||
| 751 | } | ||
| 752 | |||
| 753 | function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void { | ||
| 754 | if (resolvedHighlightCache.has(cacheKey)) { | ||
| 755 | resolvedHighlightCache.delete(cacheKey); | ||
| 756 | } | ||
| 757 | |||
| 758 | resolvedHighlightCache.set(cacheKey, highlighted); | ||
| 759 | |||
| 760 | while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) { | ||
| 761 | const oldestKey = resolvedHighlightCache.keys().next().value; | ||
| 762 | if (oldestKey === undefined) { | ||
| 763 | break; | ||
| 764 | } | ||
| 765 | resolvedHighlightCache.delete(oldestKey); | ||
| 766 | } | ||
| 767 | } | ||
| 768 | |||
| 769 | export function clearReviewHighlightFileCache(): void { | ||
| 770 | highlightCache.clear(); | ||
| 771 | resolvedHighlightCache.clear(); | ||
| 772 | } | ||
| 773 | |||
| 774 | export function getCachedHighlightedReviewFile( | ||
| 775 | file: ReviewRenderableFile, | ||
| 776 | theme: ReviewDiffTheme, | ||
| 777 | ): ReviewHighlightedFile | null { | ||
| 778 | if (REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | ||
| 779 | return null; | ||
| 780 | } | ||
| 781 | |||
| 782 | return resolvedHighlightCache.get(getHighlightCacheKey(file, theme)) ?? null; | ||
| 783 | } | ||
| 784 | |||
| 785 | export async function highlightReviewFile( | ||
| 786 | file: ReviewRenderableFile, | ||
| 787 | theme: ReviewDiffTheme, | ||
| 788 | ): Promise<ReviewHighlightedFile> { | ||
| 789 | const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; | ||
| 790 | const cacheKey = getHighlightCacheKey(file, theme); | ||
| 791 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | ||
| 792 | const resolved = resolvedHighlightCache.get(cacheKey); | ||
| 793 | if (resolved) { | ||
| 794 | logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { | ||
| 795 | fileId: file.id, | ||
| 796 | filePath: file.path, | ||
| 797 | theme, | ||
| 798 | }); | ||
| 799 | return resolved; | ||
| 800 | } | ||
| 801 | const cached = highlightCache.get(cacheKey); | ||
| 802 | if (cached) { | ||
| 803 | logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { | ||
| 804 | fileId: file.id, | ||
| 805 | filePath: file.path, | ||
| 806 | theme, | ||
| 807 | }); | ||
| 808 | return cached; | ||
| 809 | } | ||
| 810 | } | ||
| 811 | |||
| 812 | const promise = (async () => { | ||
| 813 | const startedAt = Date.now(); | ||
| 814 | logReviewHighlighterDiagnostic("file highlight start", { | ||
| 815 | fileId: file.id, | ||
| 816 | filePath: file.path, | ||
| 817 | theme, | ||
| 818 | additionLineCount: file.additionLines.length, | ||
| 819 | deletionLineCount: file.deletionLines.length, | ||
| 820 | rowCount: file.rows.length, | ||
| 821 | }); | ||
| 822 | const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); | ||
| 823 | const language = loadedLanguage ?? (await resolveLanguage(file)); | ||
| 824 | if (language === "text") { | ||
| 825 | const highlighted = applyWordAltDiffHighlightsToFile(file, { | ||
| 826 | additionLines: makePlainHighlightedLines(file.additionLines), | ||
| 827 | deletionLines: makePlainHighlightedLines(file.deletionLines), | ||
| 828 | }); | ||
| 829 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | ||
| 830 | storeResolvedHighlightedFile(cacheKey, highlighted); | ||
| 831 | } | ||
| 832 | logReviewHighlighterDiagnostic("file highlight complete", { | ||
| 833 | fileId: file.id, | ||
| 834 | filePath: file.path, | ||
| 835 | theme, | ||
| 836 | language, | ||
| 837 | highlightedAdditionLineCount: highlighted.additionLines.length, | ||
| 838 | highlightedDeletionLineCount: highlighted.deletionLines.length, | ||
| 839 | durationMs: Date.now() - startedAt, | ||
| 840 | }); | ||
| 841 | return highlighted; | ||
| 842 | } | ||
| 843 | |||
| 844 | const additionLines = await highlightLines( | ||
| 845 | joinPatchLines(file.additionLines), | ||
| 846 | language, | ||
| 847 | shikiTheme, | ||
| 848 | ); | ||
| 849 | await waitForNextFrame(); | ||
| 850 | const deletionLines = await highlightLines( | ||
| 851 | joinPatchLines(file.deletionLines), | ||
| 852 | language, | ||
| 853 | shikiTheme, | ||
| 854 | ); | ||
| 855 | await waitForNextFrame(); | ||
| 856 | |||
| 857 | const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); | ||
| 858 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | ||
| 859 | storeResolvedHighlightedFile(cacheKey, highlighted); | ||
| 860 | } | ||
| 861 | logReviewHighlighterDiagnostic("file highlight complete", { | ||
| 862 | fileId: file.id, | ||
| 863 | filePath: file.path, | ||
| 864 | theme, | ||
| 865 | language, | ||
| 866 | highlightedAdditionLineCount: highlighted.additionLines.length, | ||
| 867 | highlightedDeletionLineCount: highlighted.deletionLines.length, | ||
| 868 | durationMs: Date.now() - startedAt, | ||
| 869 | }); | ||
| 870 | return highlighted; | ||
| 871 | })(); | ||
| 872 | |||
| 873 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | ||
| 874 | highlightCache.set(cacheKey, promise); | ||
| 875 | } | ||
| 876 | return promise.finally(() => { | ||
| 877 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | ||
| 878 | highlightCache.delete(cacheKey); | ||
| 879 | } | ||
| 880 | }); | ||
| 881 | } | ||
| 882 | |||
| 883 | export async function streamHighlightReviewFile( | ||
| 884 | file: ReviewRenderableFile, | ||
| 885 | theme: ReviewDiffTheme, | ||
| 886 | onProgress: (progress: ReviewHighlightFileProgress) => void, | ||
| 887 | ): Promise<ReviewHighlightedFile> { | ||
| 888 | const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; | ||
| 889 | const cacheKey = getHighlightCacheKey(file, theme); | ||
| 890 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | ||
| 891 | const resolved = resolvedHighlightCache.get(cacheKey); | ||
| 892 | if (resolved) { | ||
| 893 | onProgress({ | ||
| 894 | highlightedFile: resolved, | ||
| 895 | complete: true, | ||
| 896 | highlightedLineCount: resolved.additionLines.length + resolved.deletionLines.length, | ||
| 897 | }); | ||
| 898 | return resolved; | ||
| 899 | } | ||
| 900 | } | ||
| 901 | |||
| 902 | const startedAt = Date.now(); | ||
| 903 | logReviewHighlighterDiagnostic("file stream highlight start", { | ||
| 904 | fileId: file.id, | ||
| 905 | filePath: file.path, | ||
| 906 | theme, | ||
| 907 | additionLineCount: file.additionLines.length, | ||
| 908 | deletionLineCount: file.deletionLines.length, | ||
| 909 | rowCount: file.rows.length, | ||
| 910 | }); | ||
| 911 | |||
| 912 | const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); | ||
| 913 | const language = loadedLanguage ?? (await resolveLanguage(file)); | ||
| 914 | if (language === "text") { | ||
| 915 | const highlighted = applyWordAltDiffHighlightsToFile(file, { | ||
| 916 | additionLines: makePlainHighlightedLines(file.additionLines), | ||
| 917 | deletionLines: makePlainHighlightedLines(file.deletionLines), | ||
| 918 | }); | ||
| 919 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | ||
| 920 | storeResolvedHighlightedFile(cacheKey, highlighted); | ||
| 921 | } | ||
| 922 | onProgress({ | ||
| 923 | highlightedFile: highlighted, | ||
| 924 | complete: true, | ||
| 925 | highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, | ||
| 926 | }); | ||
| 927 | logReviewHighlighterDiagnostic("file stream highlight complete", { | ||
| 928 | fileId: file.id, | ||
| 929 | filePath: file.path, | ||
| 930 | theme, | ||
| 931 | language, | ||
| 932 | highlightedAdditionLineCount: highlighted.additionLines.length, | ||
| 933 | highlightedDeletionLineCount: highlighted.deletionLines.length, | ||
| 934 | highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, | ||
| 935 | durationMs: Date.now() - startedAt, | ||
| 936 | }); | ||
| 937 | return highlighted; | ||
| 938 | } | ||
| 939 | |||
| 940 | const additionLines: Array<ReadonlyArray<ReviewHighlightedToken>> = []; | ||
| 941 | const deletionLines: Array<ReadonlyArray<ReviewHighlightedToken>> = []; | ||
| 942 | let highlightedLineCount = 0; | ||
| 943 | |||
| 944 | await highlightPatchLinesInChunks({ | ||
| 945 | lines: file.additionLines, | ||
| 946 | language, | ||
| 947 | theme: shikiTheme, | ||
| 948 | onChunk: (startIndex, tokens) => { | ||
| 949 | tokens.forEach((lineTokens, index) => { | ||
| 950 | additionLines[startIndex + index] = lineTokens; | ||
| 951 | }); | ||
| 952 | highlightedLineCount += tokens.length; | ||
| 953 | }, | ||
| 954 | }); | ||
| 955 | await waitForNextFrame(); | ||
| 956 | await highlightPatchLinesInChunks({ | ||
| 957 | lines: file.deletionLines, | ||
| 958 | language, | ||
| 959 | theme: shikiTheme, | ||
| 960 | onChunk: (startIndex, tokens) => { | ||
| 961 | tokens.forEach((lineTokens, index) => { | ||
| 962 | deletionLines[startIndex + index] = lineTokens; | ||
| 963 | }); | ||
| 964 | highlightedLineCount += tokens.length; | ||
| 965 | }, | ||
| 966 | }); | ||
| 967 | |||
| 968 | const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); | ||
| 969 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | ||
| 970 | storeResolvedHighlightedFile(cacheKey, highlighted); | ||
| 971 | } | ||
| 972 | onProgress({ | ||
| 973 | highlightedFile: highlighted, | ||
| 974 | complete: true, | ||
| 975 | highlightedLineCount, | ||
| 976 | }); | ||
| 977 | logReviewHighlighterDiagnostic("file stream highlight complete", { | ||
| 978 | fileId: file.id, | ||
| 979 | filePath: file.path, | ||
| 980 | theme, | ||
| 981 | language, | ||
| 982 | highlightedAdditionLineCount: highlighted.additionLines.length, | ||
| 983 | highlightedDeletionLineCount: highlighted.deletionLines.length, | ||
| 984 | highlightedLineCount, | ||
| 985 | durationMs: Date.now() - startedAt, | ||
| 986 | }); | ||
| 987 | return highlighted; | ||
| 988 | } | ||
| 989 | |||
| 990 | export async function highlightReviewSelectedLines(input: { | ||
| 991 | readonly filePath: string; | ||
| 992 | readonly lines: ReadonlyArray<ReviewRenderableLineRow>; | ||
| 993 | readonly theme: ReviewDiffTheme; | ||
| 994 | readonly languageHint?: string | null; | ||
| 995 | }): Promise<Record<string, ReadonlyArray<ReviewHighlightedToken>>> { | ||
| 996 | if (input.lines.length === 0) { | ||
| 997 | return {}; | ||
| 998 | } | ||
| 999 | |||
| 1000 | const loadedLanguage = resolveLoadedLanguageFromPath(input.filePath, input.languageHint ?? null); | ||
| 1001 | const language = | ||
| 1002 | loadedLanguage ?? (await resolveLanguageFromPath(input.filePath, input.languageHint ?? null)); | ||
| 1003 | const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[input.theme]; | ||
| 1004 | const additionLikeLines: string[] = []; | ||
| 1005 | const deletionLines: string[] = []; | ||
| 1006 | for (const line of input.lines) { | ||
| 1007 | if (line.change === "delete") { | ||
| 1008 | deletionLines.push(`${line.content}\n`); | ||
| 1009 | } else { | ||
| 1010 | additionLikeLines.push(`${line.content}\n`); | ||
| 1011 | } | ||
| 1012 | } | ||
| 1013 | const [additionTokens, deletionTokens] = await Promise.all([ | ||
| 1014 | highlightLines(joinPatchLines(additionLikeLines), language, shikiTheme), | ||
| 1015 | highlightLines(joinPatchLines(deletionLines), language, shikiTheme), | ||
| 1016 | ]); | ||
| 1017 | |||
| 1018 | const tokenMap: Record<string, ReadonlyArray<ReviewHighlightedToken>> = {}; | ||
| 1019 | let additionIndex = 0; | ||
| 1020 | let deletionIndex = 0; | ||
| 1021 | |||
| 1022 | input.lines.forEach((line) => { | ||
| 1023 | if (line.change === "delete") { | ||
| 1024 | tokenMap[line.id] = deletionTokens[deletionIndex] ?? []; | ||
| 1025 | deletionIndex += 1; | ||
| 1026 | return; | ||
| 1027 | } | ||
| 1028 | |||
| 1029 | tokenMap[line.id] = additionTokens[additionIndex] ?? []; | ||
| 1030 | additionIndex += 1; | ||
| 1031 | }); | ||
| 1032 | |||
| 1033 | return applyWordAltDiffHighlightsToSelectedLines({ | ||
| 1034 | lines: input.lines, | ||
| 1035 | tokenMap, | ||
| 1036 | }); | ||
| 1037 | } | ||
apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts created+27| ... | @@ -0,0 +1,27 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./useNativeReviewDiffBridge"; | ||
| 4 | |||
| 5 | describe("native review diff bridge", () => { | ||
| 6 | it("builds stable reset keys from the rendered diff identity", () => { | ||
| 7 | const input = { | ||
| 8 | threadKey: "env:thread", | ||
| 9 | sectionId: "turn:2", | ||
| 10 | scheme: "dark" as const, | ||
| 11 | diff: "diff --git a/a.ts b/a.ts", | ||
| 12 | fileCount: 1, | ||
| 13 | rowCount: 4, | ||
| 14 | }; | ||
| 15 | |||
| 16 | expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input)); | ||
| 17 | expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe( | ||
| 18 | buildNativeReviewTokensResetKey(input), | ||
| 19 | ); | ||
| 20 | expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:"); | ||
| 21 | }); | ||
| 22 | |||
| 23 | it("includes diff length in the hash key to reduce accidental collisions", () => { | ||
| 24 | expect(hashReviewDiffKey("abc")).toMatch(/^3:/); | ||
| 25 | expect(hashReviewDiffKey("abcd")).toMatch(/^4:/); | ||
| 26 | }); | ||
| 27 | }); | ||
apps/mobile/src/features/review/useNativeReviewDiffBridge.ts created+152| ... | @@ -0,0 +1,152 @@ | ||
| 1 | import { useCallback, useMemo, useState } from "react"; | ||
| 2 | import type { NativeSyntheticEvent } from "react-native"; | ||
| 3 | |||
| 4 | import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter"; | ||
| 5 | import { | ||
| 6 | createNativeReviewDiffTheme, | ||
| 7 | NATIVE_REVIEW_DIFF_STYLE, | ||
| 8 | type NativeReviewDiffData, | ||
| 9 | } from "./nativeReviewDiffAdapter"; | ||
| 10 | import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting"; | ||
| 11 | |||
| 12 | export function hashReviewDiffKey(diff: string | null | undefined): string { | ||
| 13 | if (!diff) { | ||
| 14 | return "empty"; | ||
| 15 | } | ||
| 16 | |||
| 17 | let hash = 5381; | ||
| 18 | for (let index = 0; index < diff.length; index += 1) { | ||
| 19 | hash = (hash * 33) ^ diff.charCodeAt(index); | ||
| 20 | } | ||
| 21 | |||
| 22 | return `${diff.length}:${(hash >>> 0).toString(36)}`; | ||
| 23 | } | ||
| 24 | |||
| 25 | export function buildNativeReviewTokensResetKey(input: { | ||
| 26 | readonly threadKey: string | null; | ||
| 27 | readonly sectionId: string | null; | ||
| 28 | readonly scheme: NativeReviewDiffHighlightScheme; | ||
| 29 | readonly diff: string | null | undefined; | ||
| 30 | readonly fileCount: number; | ||
| 31 | readonly rowCount: number; | ||
| 32 | }): string { | ||
| 33 | return [ | ||
| 34 | input.threadKey ?? "none", | ||
| 35 | input.sectionId ?? "none", | ||
| 36 | input.scheme, | ||
| 37 | hashReviewDiffKey(input.diff), | ||
| 38 | input.fileCount, | ||
| 39 | input.rowCount, | ||
| 40 | ].join(":"); | ||
| 41 | } | ||
| 42 | |||
| 43 | export function useNativeReviewDiffBridge(input: { | ||
| 44 | readonly threadKey: string | null; | ||
| 45 | readonly sectionId: string | null; | ||
| 46 | readonly diff: string | null | undefined; | ||
| 47 | readonly data: NativeReviewDiffData; | ||
| 48 | readonly scheme: NativeReviewDiffHighlightScheme; | ||
| 49 | readonly collapsedFileIds: ReadonlyArray<string>; | ||
| 50 | readonly viewedFileIds: ReadonlyArray<string>; | ||
| 51 | readonly selectedRowIds: ReadonlyArray<string>; | ||
| 52 | readonly canHighlight: boolean; | ||
| 53 | }) { | ||
| 54 | const { | ||
| 55 | canHighlight, | ||
| 56 | collapsedFileIds, | ||
| 57 | data, | ||
| 58 | diff, | ||
| 59 | scheme, | ||
| 60 | sectionId, | ||
| 61 | selectedRowIds, | ||
| 62 | threadKey, | ||
| 63 | viewedFileIds, | ||
| 64 | } = input; | ||
| 65 | const [collapsedCommentIds, setCollapsedCommentIds] = useState<ReadonlySet<string>>( | ||
| 66 | () => new Set(), | ||
| 67 | ); | ||
| 68 | |||
| 69 | const theme = useMemo(() => createNativeReviewDiffTheme(scheme), [scheme]); | ||
| 70 | const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); | ||
| 71 | const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); | ||
| 72 | const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]); | ||
| 73 | const selectedRowIdsJson = useMemo(() => JSON.stringify(selectedRowIds), [selectedRowIds]); | ||
| 74 | const collapsedCommentIdsJson = useMemo( | ||
| 75 | () => JSON.stringify(Array.from(collapsedCommentIds)), | ||
| 76 | [collapsedCommentIds], | ||
| 77 | ); | ||
| 78 | const themeJson = useMemo(() => JSON.stringify(theme), [theme]); | ||
| 79 | const styleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []); | ||
| 80 | const tokensResetKey = useMemo( | ||
| 81 | () => | ||
| 82 | buildNativeReviewTokensResetKey({ | ||
| 83 | threadKey, | ||
| 84 | sectionId, | ||
| 85 | scheme, | ||
| 86 | diff, | ||
| 87 | fileCount: data.files.length, | ||
| 88 | rowCount: data.rows.length, | ||
| 89 | }), | ||
| 90 | [data.files.length, data.rows.length, diff, scheme, sectionId, threadKey], | ||
| 91 | ); | ||
| 92 | const { tokensPatchJson, updateVisibleRange } = useNativeReviewDiffHighlighting({ | ||
| 93 | files: data.files, | ||
| 94 | rows: data.rows, | ||
| 95 | scheme, | ||
| 96 | resetKey: tokensResetKey, | ||
| 97 | enabled: canHighlight, | ||
| 98 | }); | ||
| 99 | |||
| 100 | const onDebug = useCallback( | ||
| 101 | (event: NativeSyntheticEvent<Record<string, unknown>>) => { | ||
| 102 | const payload = event.nativeEvent; | ||
| 103 | const message = payload.message; | ||
| 104 | if ( | ||
| 105 | (message === "draw-metrics" || message === "visible-range") && | ||
| 106 | typeof payload.firstRowIndex === "number" && | ||
| 107 | typeof payload.lastRowIndex === "number" | ||
| 108 | ) { | ||
| 109 | updateVisibleRange({ | ||
| 110 | firstRowIndex: payload.firstRowIndex, | ||
| 111 | lastRowIndex: payload.lastRowIndex, | ||
| 112 | }); | ||
| 113 | } | ||
| 114 | }, | ||
| 115 | [updateVisibleRange], | ||
| 116 | ); | ||
| 117 | |||
| 118 | const onToggleComment = useCallback( | ||
| 119 | (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => { | ||
| 120 | const { commentId } = event.nativeEvent; | ||
| 121 | if (!commentId) { | ||
| 122 | return; | ||
| 123 | } | ||
| 124 | |||
| 125 | setCollapsedCommentIds((current) => { | ||
| 126 | const next = new Set(current); | ||
| 127 | if (next.has(commentId)) { | ||
| 128 | next.delete(commentId); | ||
| 129 | } else { | ||
| 130 | next.add(commentId); | ||
| 131 | } | ||
| 132 | return next; | ||
| 133 | }); | ||
| 134 | }, | ||
| 135 | [], | ||
| 136 | ); | ||
| 137 | |||
| 138 | return { | ||
| 139 | theme, | ||
| 140 | rowsJson, | ||
| 141 | collapsedFileIdsJson, | ||
| 142 | collapsedCommentIdsJson, | ||
| 143 | viewedFileIdsJson, | ||
| 144 | selectedRowIdsJson, | ||
| 145 | themeJson, | ||
| 146 | styleJson, | ||
| 147 | tokensPatchJson, | ||
| 148 | tokensResetKey, | ||
| 149 | onDebug, | ||
| 150 | onToggleComment, | ||
| 151 | }; | ||
| 152 | } | ||
apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts created+136| ... | @@ -0,0 +1,136 @@ | ||
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | highlightNativeReviewDiffVisibleRows, | ||
| 5 | type NativeReviewDiffHighlightEngine, | ||
| 6 | type NativeReviewDiffHighlightScheme, | ||
| 7 | } from "../diffs/nativeReviewDiffHighlighter"; | ||
| 8 | import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface"; | ||
| 9 | import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; | ||
| 10 | |||
| 11 | interface NativeReviewVisibleRange { | ||
| 12 | readonly firstRowIndex: number; | ||
| 13 | readonly lastRowIndex: number; | ||
| 14 | } | ||
| 15 | |||
| 16 | function createEmptyTokenPatch(resetKey: string): string { | ||
| 17 | return JSON.stringify({ resetKey, tokensByRowId: {} }); | ||
| 18 | } | ||
| 19 | |||
| 20 | function isReviewDiffDebugLoggingEnabled(): boolean { | ||
| 21 | return typeof __DEV__ !== "undefined" ? __DEV__ : false; | ||
| 22 | } | ||
| 23 | |||
| 24 | function logReviewDiffDiagnostic(message: string, details?: Record<string, unknown>): void { | ||
| 25 | if (!isReviewDiffDebugLoggingEnabled()) { | ||
| 26 | return; | ||
| 27 | } | ||
| 28 | |||
| 29 | if (details) { | ||
| 30 | console.log(`[review-sheet] ${message}`, details); | ||
| 31 | return; | ||
| 32 | } | ||
| 33 | |||
| 34 | console.log(`[review-sheet] ${message}`); | ||
| 35 | } | ||
| 36 | |||
| 37 | export function useNativeReviewDiffHighlighting(input: { | ||
| 38 | readonly files: ReadonlyArray<NativeReviewDiffFile>; | ||
| 39 | readonly rows: ReadonlyArray<NativeReviewDiffRow>; | ||
| 40 | readonly scheme: NativeReviewDiffHighlightScheme; | ||
| 41 | readonly resetKey: string; | ||
| 42 | readonly enabled: boolean; | ||
| 43 | }) { | ||
| 44 | const { enabled, files, resetKey, rows, scheme } = input; | ||
| 45 | const highlightedRowIdsRef = useRef<Set<string>>(new Set()); | ||
| 46 | const visibleRangeRef = useRef<NativeReviewVisibleRange>({ | ||
| 47 | firstRowIndex: 0, | ||
| 48 | lastRowIndex: 80, | ||
| 49 | }); | ||
| 50 | const visibleChunkIndexRef = useRef(0); | ||
| 51 | const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey)); | ||
| 52 | const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0); | ||
| 53 | |||
| 54 | useEffect(() => { | ||
| 55 | highlightedRowIdsRef.current = new Set(); | ||
| 56 | visibleChunkIndexRef.current = 0; | ||
| 57 | visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 }; | ||
| 58 | setTokensPatchJson(createEmptyTokenPatch(resetKey)); | ||
| 59 | if (enabled && rows.length > 0) { | ||
| 60 | setVisibleHighlightRequest((request) => request + 1); | ||
| 61 | } | ||
| 62 | }, [enabled, resetKey, rows.length]); | ||
| 63 | |||
| 64 | useEffect(() => { | ||
| 65 | if (!enabled || rows.length === 0) { | ||
| 66 | return; | ||
| 67 | } | ||
| 68 | |||
| 69 | const abortController = new AbortController(); | ||
| 70 | const requestRange = visibleRangeRef.current; | ||
| 71 | const engine: NativeReviewDiffHighlightEngine = "native"; | ||
| 72 | |||
| 73 | void (async () => { | ||
| 74 | try { | ||
| 75 | const result = await highlightNativeReviewDiffVisibleRows({ | ||
| 76 | files, | ||
| 77 | rows, | ||
| 78 | scheme, | ||
| 79 | engine, | ||
| 80 | firstRowIndex: requestRange.firstRowIndex, | ||
| 81 | lastRowIndex: requestRange.lastRowIndex, | ||
| 82 | alreadyHighlightedRowIds: highlightedRowIdsRef.current, | ||
| 83 | signal: abortController.signal, | ||
| 84 | }); | ||
| 85 | |||
| 86 | if (abortController.signal.aborted || result.rowCount === 0) { | ||
| 87 | return; | ||
| 88 | } | ||
| 89 | |||
| 90 | for (const rowId of Object.keys(result.tokensByRowId)) { | ||
| 91 | highlightedRowIdsRef.current.add(rowId); | ||
| 92 | } | ||
| 93 | |||
| 94 | const chunkIndex = visibleChunkIndexRef.current; | ||
| 95 | visibleChunkIndexRef.current += 1; | ||
| 96 | setTokensPatchJson( | ||
| 97 | JSON.stringify({ | ||
| 98 | resetKey, | ||
| 99 | chunkIndex, | ||
| 100 | fileId: "visible", | ||
| 101 | filePath: "visible rows", | ||
| 102 | language: "diff", | ||
| 103 | lineCount: result.rowCount, | ||
| 104 | durationMs: result.durationMs, | ||
| 105 | tokensByRowId: result.tokensByRowId, | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | } catch (error) { | ||
| 109 | if (!abortController.signal.aborted) { | ||
| 110 | logReviewDiffDiagnostic("native visible highlight failed", { | ||
| 111 | error: error instanceof Error ? error.message : String(error), | ||
| 112 | }); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | })(); | ||
| 116 | |||
| 117 | return () => abortController.abort(); | ||
| 118 | }, [enabled, files, resetKey, rows, scheme, visibleHighlightRequest]); | ||
| 119 | |||
| 120 | const updateVisibleRange = useCallback((nextRange: NativeReviewVisibleRange) => { | ||
| 121 | const previousRange = visibleRangeRef.current; | ||
| 122 | const movedRows = | ||
| 123 | Math.abs(nextRange.firstRowIndex - previousRange.firstRowIndex) + | ||
| 124 | Math.abs(nextRange.lastRowIndex - previousRange.lastRowIndex); | ||
| 125 | |||
| 126 | visibleRangeRef.current = nextRange; | ||
| 127 | if (movedRows >= 20) { | ||
| 128 | setVisibleHighlightRequest((request) => request + 1); | ||
| 129 | } | ||
| 130 | }, []); | ||
| 131 | |||
| 132 | return { | ||
| 133 | tokensPatchJson, | ||
| 134 | updateVisibleRange, | ||
| 135 | }; | ||
| 136 | } | ||
apps/mobile/src/features/review/useReviewCommentSelectionController.ts created+199| ... | @@ -0,0 +1,199 @@ | ||
| 1 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 2 | import type { NativeSyntheticEvent } from "react-native"; | ||
| 3 | import { useRouter } from "expo-router"; | ||
| 4 | import * as Arr from "effect/Array"; | ||
| 5 | import { pipe } from "effect/Function"; | ||
| 6 | import * as Result from "effect/Result"; | ||
| 7 | |||
| 8 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | buildReviewCommentTarget, | ||
| 12 | clearReviewCommentTarget, | ||
| 13 | formatReviewSelectedRangeLabel, | ||
| 14 | getSelectedReviewCommentLines, | ||
| 15 | setReviewCommentTarget, | ||
| 16 | useReviewCommentTarget, | ||
| 17 | } from "./reviewCommentSelection"; | ||
| 18 | import type { | ||
| 19 | NativeReviewDiffData, | ||
| 20 | NativeReviewDiffCommentTarget, | ||
| 21 | } from "./nativeReviewDiffAdapter"; | ||
| 22 | import type { ReviewSectionItem } from "./reviewModel"; | ||
| 23 | |||
| 24 | interface PendingNativeCommentSelection extends NativeReviewDiffCommentTarget { | ||
| 25 | readonly sectionId: string; | ||
| 26 | readonly sectionTitle: string; | ||
| 27 | readonly rowId: string; | ||
| 28 | } | ||
| 29 | |||
| 30 | export function useReviewCommentSelectionController(input: { | ||
| 31 | readonly environmentId?: EnvironmentId; | ||
| 32 | readonly threadId?: ThreadId; | ||
| 33 | readonly selectedSection: ReviewSectionItem | null; | ||
| 34 | readonly nativeReviewDiffData: NativeReviewDiffData; | ||
| 35 | }) { | ||
| 36 | const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input; | ||
| 37 | const { push } = useRouter(); | ||
| 38 | const activeCommentTarget = useReviewCommentTarget(); | ||
| 39 | const [pendingNativeCommentSelection, setPendingNativeCommentSelection] = | ||
| 40 | useState<PendingNativeCommentSelection | null>(null); | ||
| 41 | |||
| 42 | const openReviewCommentSheet = useCallback(() => { | ||
| 43 | if (!environmentId || !threadId) { | ||
| 44 | return; | ||
| 45 | } | ||
| 46 | |||
| 47 | push({ | ||
| 48 | pathname: "/threads/[environmentId]/[threadId]/review-comment", | ||
| 49 | params: { environmentId, threadId }, | ||
| 50 | }); | ||
| 51 | }, [environmentId, push, threadId]); | ||
| 52 | |||
| 53 | const selectedRowIds = useMemo(() => { | ||
| 54 | if ( | ||
| 55 | activeCommentTarget && | ||
| 56 | activeCommentTarget.sectionTitle === selectedSection?.title && | ||
| 57 | activeCommentTarget.startIndex !== activeCommentTarget.endIndex | ||
| 58 | ) { | ||
| 59 | return pipe( | ||
| 60 | getSelectedReviewCommentLines(activeCommentTarget), | ||
| 61 | Arr.filterMap((line) => { | ||
| 62 | const rowId = nativeReviewDiffData.rowIdByCommentLineId.get(line.id); | ||
| 63 | return rowId ? Result.succeed(rowId) : Result.failVoid; | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | |||
| 68 | return pendingNativeCommentSelection ? [pendingNativeCommentSelection.rowId] : []; | ||
| 69 | }, [ | ||
| 70 | activeCommentTarget, | ||
| 71 | nativeReviewDiffData.rowIdByCommentLineId, | ||
| 72 | pendingNativeCommentSelection, | ||
| 73 | selectedSection?.title, | ||
| 74 | ]); | ||
| 75 | |||
| 76 | const selectionAction = useMemo(() => { | ||
| 77 | if ( | ||
| 78 | activeCommentTarget && | ||
| 79 | activeCommentTarget.sectionTitle === selectedSection?.title && | ||
| 80 | activeCommentTarget.startIndex !== activeCommentTarget.endIndex | ||
| 81 | ) { | ||
| 82 | return { | ||
| 83 | title: `Comment on ${formatReviewSelectedRangeLabel(activeCommentTarget)}`, | ||
| 84 | onOpenComment: openReviewCommentSheet, | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | |||
| 88 | if ( | ||
| 89 | pendingNativeCommentSelection && | ||
| 90 | pendingNativeCommentSelection.sectionTitle === selectedSection?.title | ||
| 91 | ) { | ||
| 92 | return { | ||
| 93 | title: "Select range end", | ||
| 94 | onOpenComment: null, | ||
| 95 | }; | ||
| 96 | } | ||
| 97 | |||
| 98 | return null; | ||
| 99 | }, [ | ||
| 100 | activeCommentTarget, | ||
| 101 | openReviewCommentSheet, | ||
| 102 | pendingNativeCommentSelection, | ||
| 103 | selectedSection?.title, | ||
| 104 | ]); | ||
| 105 | |||
| 106 | useEffect(() => { | ||
| 107 | clearReviewCommentTarget(); | ||
| 108 | setPendingNativeCommentSelection(null); | ||
| 109 | }, [selectedSection?.id]); | ||
| 110 | |||
| 111 | useEffect(() => { | ||
| 112 | if (activeCommentTarget === null) { | ||
| 113 | setPendingNativeCommentSelection(null); | ||
| 114 | } | ||
| 115 | }, [activeCommentTarget]); | ||
| 116 | |||
| 117 | const onPressLine = useCallback( | ||
| 118 | ( | ||
| 119 | event: NativeSyntheticEvent<{ | ||
| 120 | readonly rowId?: string; | ||
| 121 | readonly gesture?: "tap" | "longPress"; | ||
| 122 | }>, | ||
| 123 | ) => { | ||
| 124 | if (!selectedSection) { | ||
| 125 | return; | ||
| 126 | } | ||
| 127 | |||
| 128 | const { rowId, gesture } = event.nativeEvent; | ||
| 129 | if (!rowId) { | ||
| 130 | return; | ||
| 131 | } | ||
| 132 | |||
| 133 | const target = nativeReviewDiffData.commentTargetsByRowId.get(rowId); | ||
| 134 | if (!target) { | ||
| 135 | return; | ||
| 136 | } | ||
| 137 | |||
| 138 | if (gesture === "longPress") { | ||
| 139 | clearReviewCommentTarget(); | ||
| 140 | setPendingNativeCommentSelection({ | ||
| 141 | ...target, | ||
| 142 | sectionId: selectedSection.id, | ||
| 143 | sectionTitle: selectedSection.title, | ||
| 144 | rowId, | ||
| 145 | }); | ||
| 146 | return; | ||
| 147 | } | ||
| 148 | |||
| 149 | if ( | ||
| 150 | pendingNativeCommentSelection && | ||
| 151 | pendingNativeCommentSelection.sectionTitle === selectedSection.title && | ||
| 152 | pendingNativeCommentSelection.filePath === target.filePath | ||
| 153 | ) { | ||
| 154 | setReviewCommentTarget( | ||
| 155 | buildReviewCommentTarget( | ||
| 156 | { | ||
| 157 | sectionTitle: pendingNativeCommentSelection.sectionTitle, | ||
| 158 | sectionId: pendingNativeCommentSelection.sectionId, | ||
| 159 | filePath: pendingNativeCommentSelection.filePath, | ||
| 160 | lines: pendingNativeCommentSelection.lines, | ||
| 161 | }, | ||
| 162 | pendingNativeCommentSelection.lineIndex, | ||
| 163 | target.lineIndex, | ||
| 164 | ), | ||
| 165 | ); | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | |||
| 169 | setPendingNativeCommentSelection(null); | ||
| 170 | setReviewCommentTarget({ | ||
| 171 | sectionTitle: selectedSection.title, | ||
| 172 | sectionId: selectedSection.id, | ||
| 173 | filePath: target.filePath, | ||
| 174 | lines: target.lines, | ||
| 175 | startIndex: target.lineIndex, | ||
| 176 | endIndex: target.lineIndex, | ||
| 177 | }); | ||
| 178 | openReviewCommentSheet(); | ||
| 179 | }, | ||
| 180 | [ | ||
| 181 | nativeReviewDiffData.commentTargetsByRowId, | ||
| 182 | openReviewCommentSheet, | ||
| 183 | pendingNativeCommentSelection, | ||
| 184 | selectedSection, | ||
| 185 | ], | ||
| 186 | ); | ||
| 187 | |||
| 188 | const clearSelection = useCallback(() => { | ||
| 189 | clearReviewCommentTarget(); | ||
| 190 | setPendingNativeCommentSelection(null); | ||
| 191 | }, []); | ||
| 192 | |||
| 193 | return { | ||
| 194 | selectedRowIds, | ||
| 195 | selectionAction, | ||
| 196 | onPressLine, | ||
| 197 | clearSelection, | ||
| 198 | }; | ||
| 199 | } | ||
apps/mobile/src/features/review/useReviewDiffData.ts created+109| ... | @@ -0,0 +1,109 @@ | ||
| 1 | import { useEffect, useMemo } from "react"; | ||
| 2 | |||
| 3 | import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; | ||
| 4 | import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter"; | ||
| 5 | import { markReviewEvent, measureReviewWork } from "./reviewPerf"; | ||
| 6 | import { getCachedReviewParsedDiff } from "./reviewState"; | ||
| 7 | import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel"; | ||
| 8 | |||
| 9 | function isReviewDiffDebugLoggingEnabled(): boolean { | ||
| 10 | return typeof __DEV__ !== "undefined" ? __DEV__ : false; | ||
| 11 | } | ||
| 12 | |||
| 13 | function logReviewDiffDiagnostic(message: string, details?: Record<string, unknown>): void { | ||
| 14 | if (!isReviewDiffDebugLoggingEnabled()) { | ||
| 15 | return; | ||
| 16 | } | ||
| 17 | |||
| 18 | if (details) { | ||
| 19 | console.log(`[review-sheet] ${message}`, details); | ||
| 20 | return; | ||
| 21 | } | ||
| 22 | |||
| 23 | console.log(`[review-sheet] ${message}`); | ||
| 24 | } | ||
| 25 | |||
| 26 | export function formatHeaderDiffSummary(parsedDiff: ReviewParsedDiff): { | ||
| 27 | readonly additions: string | null; | ||
| 28 | readonly deletions: string | null; | ||
| 29 | } { | ||
| 30 | if (parsedDiff.kind !== "files") { | ||
| 31 | return { additions: null, deletions: null }; | ||
| 32 | } | ||
| 33 | |||
| 34 | return { | ||
| 35 | additions: `+${parsedDiff.additions}`, | ||
| 36 | deletions: `-${parsedDiff.deletions}`, | ||
| 37 | }; | ||
| 38 | } | ||
| 39 | |||
| 40 | export function useReviewDiffData(input: { | ||
| 41 | readonly threadKey: string | null; | ||
| 42 | readonly selectedSection: ReviewSectionItem | null; | ||
| 43 | readonly draftMessage: string; | ||
| 44 | }) { | ||
| 45 | const { draftMessage, selectedSection, threadKey } = input; | ||
| 46 | const parsedDiff = useMemo( | ||
| 47 | () => | ||
| 48 | measureReviewWork("parse-diff", () => | ||
| 49 | getCachedReviewParsedDiff({ | ||
| 50 | threadKey, | ||
| 51 | sectionId: selectedSection?.id ?? null, | ||
| 52 | diff: selectedSection?.diff, | ||
| 53 | }), | ||
| 54 | ), | ||
| 55 | [selectedSection?.diff, selectedSection?.id, threadKey], | ||
| 56 | ); | ||
| 57 | const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]); | ||
| 58 | const inlineReviewComments = useMemo( | ||
| 59 | () => parseReviewInlineComments(draftMessage), | ||
| 60 | [draftMessage], | ||
| 61 | ); | ||
| 62 | const selectedSectionInlineComments = useMemo( | ||
| 63 | () => | ||
| 64 | selectedSection | ||
| 65 | ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id) | ||
| 66 | : [], | ||
| 67 | [inlineReviewComments, selectedSection], | ||
| 68 | ); | ||
| 69 | const nativeReviewDiffData = useMemo( | ||
| 70 | () => | ||
| 71 | measureReviewWork("build-native-diff-data", () => | ||
| 72 | buildNativeReviewDiffData({ | ||
| 73 | parsedDiff, | ||
| 74 | comments: selectedSectionInlineComments, | ||
| 75 | }), | ||
| 76 | ), | ||
| 77 | [parsedDiff, selectedSectionInlineComments], | ||
| 78 | ); | ||
| 79 | const pendingReviewCommentCount = useMemo( | ||
| 80 | () => countReviewCommentContexts(draftMessage), | ||
| 81 | [draftMessage], | ||
| 82 | ); | ||
| 83 | |||
| 84 | useEffect(() => { | ||
| 85 | if (parsedDiff.kind !== "files") { | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | |||
| 89 | markReviewEvent("parsed-diff-ready", { | ||
| 90 | sectionId: selectedSection?.id ?? null, | ||
| 91 | fileCount: parsedDiff.fileCount, | ||
| 92 | additions: parsedDiff.additions, | ||
| 93 | deletions: parsedDiff.deletions, | ||
| 94 | renderedItems: nativeReviewDiffData.rows.length, | ||
| 95 | }); | ||
| 96 | logReviewDiffDiagnostic("parsed diff files", { | ||
| 97 | selectedSectionId: selectedSection?.id ?? null, | ||
| 98 | fileCount: parsedDiff.fileCount, | ||
| 99 | renderableFileCount: parsedDiff.files.length, | ||
| 100 | }); | ||
| 101 | }, [nativeReviewDiffData.rows.length, parsedDiff, selectedSection?.id]); | ||
| 102 | |||
| 103 | return { | ||
| 104 | parsedDiff, | ||
| 105 | headerDiffSummary, | ||
| 106 | nativeReviewDiffData, | ||
| 107 | pendingReviewCommentCount, | ||
| 108 | }; | ||
| 109 | } | ||
apps/mobile/src/features/review/useReviewSections.ts created+252| ... | @@ -0,0 +1,252 @@ | ||
| 1 | import { useCallback, useEffect, useMemo, useRef } from "react"; | ||
| 2 | |||
| 3 | import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | ||
| 6 | import { checkpointDiffManager, loadCheckpointDiff } from "../../state/use-checkpoint-diff"; | ||
| 7 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | ||
| 8 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | ||
| 9 | import { useReviewDiffPreview } from "./reviewDiffPreviewState"; | ||
| 10 | import { | ||
| 11 | buildReviewSectionItems, | ||
| 12 | getDefaultReviewSectionId, | ||
| 13 | getReadyReviewCheckpoints, | ||
| 14 | getReviewSectionIdForCheckpoint, | ||
| 15 | } from "./reviewModel"; | ||
| 16 | import { | ||
| 17 | setReviewAsyncError, | ||
| 18 | setReviewGitSections, | ||
| 19 | setReviewSelectedSectionId, | ||
| 20 | setReviewTurnDiffLoading, | ||
| 21 | setReviewTurnDiff, | ||
| 22 | type ReviewCacheForThread, | ||
| 23 | } from "./reviewState"; | ||
| 24 | |||
| 25 | export function useReviewSections(input: { | ||
| 26 | readonly environmentId?: EnvironmentId; | ||
| 27 | readonly threadId?: ThreadId; | ||
| 28 | readonly reviewCache: ReviewCacheForThread; | ||
| 29 | }) { | ||
| 30 | const { environmentId, reviewCache, threadId } = input; | ||
| 31 | const selectedThread = useSelectedThreadDetail(); | ||
| 32 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | ||
| 33 | const diffPreview = useReviewDiffPreview({ environmentId, cwd: selectedThreadCwd }); | ||
| 34 | const refreshDiffPreview = diffPreview.refresh; | ||
| 35 | const { loadingTurnIds } = reviewCache.asyncState; | ||
| 36 | const error = diffPreview.error ?? reviewCache.asyncState.error; | ||
| 37 | const loadingGitDiffs = diffPreview.isPending; | ||
| 38 | const turnDiffByIdRef = useRef(reviewCache.turnDiffById); | ||
| 39 | |||
| 40 | useEffect(() => { | ||
| 41 | turnDiffByIdRef.current = reviewCache.turnDiffById; | ||
| 42 | }, [reviewCache.turnDiffById]); | ||
| 43 | |||
| 44 | useEffect(() => { | ||
| 45 | if (reviewCache.threadKey && diffPreview.data) { | ||
| 46 | setReviewGitSections(reviewCache.threadKey, diffPreview.data.sources); | ||
| 47 | } | ||
| 48 | }, [diffPreview.data, reviewCache.threadKey]); | ||
| 49 | |||
| 50 | const readyCheckpoints = useMemo( | ||
| 51 | () => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []), | ||
| 52 | [selectedThread?.checkpoints], | ||
| 53 | ); | ||
| 54 | const checkpointBySectionId = useMemo(() => { | ||
| 55 | return Object.fromEntries( | ||
| 56 | readyCheckpoints.map((checkpoint) => [ | ||
| 57 | getReviewSectionIdForCheckpoint(checkpoint), | ||
| 58 | checkpoint, | ||
| 59 | ]), | ||
| 60 | ) as Record<string, OrchestrationCheckpointSummary>; | ||
| 61 | }, [readyCheckpoints]); | ||
| 62 | const reviewSections = useMemo( | ||
| 63 | () => | ||
| 64 | buildReviewSectionItems({ | ||
| 65 | checkpoints: readyCheckpoints, | ||
| 66 | gitSections: reviewCache.gitSections, | ||
| 67 | turnDiffById: reviewCache.turnDiffById, | ||
| 68 | loadingTurnIds, | ||
| 69 | loadingGitSections: diffPreview.isPending, | ||
| 70 | }), | ||
| 71 | [ | ||
| 72 | diffPreview.isPending, | ||
| 73 | loadingTurnIds, | ||
| 74 | readyCheckpoints, | ||
| 75 | reviewCache.gitSections, | ||
| 76 | reviewCache.turnDiffById, | ||
| 77 | ], | ||
| 78 | ); | ||
| 79 | const selectedSection = useMemo( | ||
| 80 | () => | ||
| 81 | reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ?? | ||
| 82 | reviewSections[0] ?? | ||
| 83 | null, | ||
| 84 | [reviewCache.selectedSectionId, reviewSections], | ||
| 85 | ); | ||
| 86 | const fallbackSectionId = useMemo( | ||
| 87 | () => getDefaultReviewSectionId(reviewSections), | ||
| 88 | [reviewSections], | ||
| 89 | ); | ||
| 90 | const hasReviewSections = reviewSections.length > 0; | ||
| 91 | const selectedSectionIdExists = useMemo( | ||
| 92 | () => | ||
| 93 | reviewCache.selectedSectionId | ||
| 94 | ? reviewSections.some((section) => section.id === reviewCache.selectedSectionId) | ||
| 95 | : false, | ||
| 96 | [reviewCache.selectedSectionId, reviewSections], | ||
| 97 | ); | ||
| 98 | |||
| 99 | const loadTurnDiff = useCallback( | ||
| 100 | async (checkpoint: OrchestrationCheckpointSummary, force = false) => { | ||
| 101 | if (!environmentId || !threadId) { | ||
| 102 | return; | ||
| 103 | } | ||
| 104 | |||
| 105 | const sectionId = getReviewSectionIdForCheckpoint(checkpoint); | ||
| 106 | if (reviewCache.threadKey) { | ||
| 107 | setReviewSelectedSectionId(reviewCache.threadKey, sectionId); | ||
| 108 | } | ||
| 109 | |||
| 110 | if (!force && turnDiffByIdRef.current[sectionId] !== undefined) { | ||
| 111 | return; | ||
| 112 | } | ||
| 113 | |||
| 114 | const target = { | ||
| 115 | environmentId, | ||
| 116 | threadId, | ||
| 117 | fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1), | ||
| 118 | toTurnCount: checkpoint.checkpointTurnCount, | ||
| 119 | ignoreWhitespace: false, | ||
| 120 | cacheScope: sectionId, | ||
| 121 | }; | ||
| 122 | const cached = checkpointDiffManager.getSnapshot(target).data; | ||
| 123 | if (!force && cached) { | ||
| 124 | if (reviewCache.threadKey) { | ||
| 125 | setReviewTurnDiff(reviewCache.threadKey, sectionId, cached.diff); | ||
| 126 | } | ||
| 127 | return; | ||
| 128 | } | ||
| 129 | |||
| 130 | if (!getEnvironmentClient(environmentId)) { | ||
| 131 | if (reviewCache.threadKey) { | ||
| 132 | setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready."); | ||
| 133 | } | ||
| 134 | return; | ||
| 135 | } | ||
| 136 | |||
| 137 | if (reviewCache.threadKey) { | ||
| 138 | setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true); | ||
| 139 | setReviewAsyncError(reviewCache.threadKey, null); | ||
| 140 | } | ||
| 141 | try { | ||
| 142 | const result = await loadCheckpointDiff(target, { force }); | ||
| 143 | if (reviewCache.threadKey) { | ||
| 144 | if (result) { | ||
| 145 | setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff); | ||
| 146 | } | ||
| 147 | } | ||
| 148 | } catch (cause) { | ||
| 149 | if (reviewCache.threadKey) { | ||
| 150 | setReviewAsyncError( | ||
| 151 | reviewCache.threadKey, | ||
| 152 | cause instanceof Error ? cause.message : "Failed to load turn diff.", | ||
| 153 | ); | ||
| 154 | } | ||
| 155 | } finally { | ||
| 156 | if (reviewCache.threadKey) { | ||
| 157 | setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false); | ||
| 158 | } | ||
| 159 | } | ||
| 160 | }, | ||
| 161 | [environmentId, reviewCache.threadKey, threadId], | ||
| 162 | ); | ||
| 163 | |||
| 164 | useEffect(() => { | ||
| 165 | if (!hasReviewSections) { | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | |||
| 169 | if (reviewCache.threadKey && (!reviewCache.selectedSectionId || !selectedSectionIdExists)) { | ||
| 170 | setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId); | ||
| 171 | } | ||
| 172 | }, [ | ||
| 173 | fallbackSectionId, | ||
| 174 | hasReviewSections, | ||
| 175 | reviewCache.selectedSectionId, | ||
| 176 | reviewCache.threadKey, | ||
| 177 | selectedSectionIdExists, | ||
| 178 | ]); | ||
| 179 | |||
| 180 | const latestCheckpoint = readyCheckpoints[0] ?? null; | ||
| 181 | const latestSectionId = latestCheckpoint | ||
| 182 | ? getReviewSectionIdForCheckpoint(latestCheckpoint) | ||
| 183 | : null; | ||
| 184 | const latestTurnDiffLoaded = latestSectionId | ||
| 185 | ? reviewCache.turnDiffById[latestSectionId] !== undefined | ||
| 186 | : true; | ||
| 187 | const latestTurnDiffLoading = latestSectionId ? loadingTurnIds[latestSectionId] === true : false; | ||
| 188 | |||
| 189 | useEffect(() => { | ||
| 190 | if (!latestCheckpoint || !latestSectionId || latestTurnDiffLoaded || latestTurnDiffLoading) { | ||
| 191 | return; | ||
| 192 | } | ||
| 193 | |||
| 194 | void loadTurnDiff(latestCheckpoint); | ||
| 195 | }, [ | ||
| 196 | latestCheckpoint, | ||
| 197 | latestSectionId, | ||
| 198 | latestTurnDiffLoaded, | ||
| 199 | latestTurnDiffLoading, | ||
| 200 | loadTurnDiff, | ||
| 201 | ]); | ||
| 202 | |||
| 203 | const selectedTurnCheckpoint = | ||
| 204 | selectedSection?.kind === "turn" ? (checkpointBySectionId[selectedSection.id] ?? null) : null; | ||
| 205 | const selectedTurnDiffMissing = | ||
| 206 | selectedSection?.kind === "turn" && selectedSection.diff === null && selectedTurnCheckpoint; | ||
| 207 | const selectedTurnDiffLoading = | ||
| 208 | selectedSection?.kind === "turn" ? loadingTurnIds[selectedSection.id] === true : false; | ||
| 209 | |||
| 210 | useEffect(() => { | ||
| 211 | if (!selectedTurnDiffMissing || selectedTurnDiffLoading) { | ||
| 212 | return; | ||
| 213 | } | ||
| 214 | |||
| 215 | void loadTurnDiff(selectedTurnDiffMissing); | ||
| 216 | }, [loadTurnDiff, selectedTurnDiffLoading, selectedTurnDiffMissing]); | ||
| 217 | |||
| 218 | const refreshSelectedSection = useCallback(async () => { | ||
| 219 | if (!selectedSection) { | ||
| 220 | return; | ||
| 221 | } | ||
| 222 | |||
| 223 | if (selectedSection.kind === "turn") { | ||
| 224 | const checkpoint = checkpointBySectionId[selectedSection.id]; | ||
| 225 | if (checkpoint) { | ||
| 226 | await loadTurnDiff(checkpoint, true); | ||
| 227 | } | ||
| 228 | return; | ||
| 229 | } | ||
| 230 | |||
| 231 | refreshDiffPreview(); | ||
| 232 | }, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]); | ||
| 233 | |||
| 234 | const selectSection = useCallback( | ||
| 235 | (sectionId: string) => { | ||
| 236 | if (reviewCache.threadKey) { | ||
| 237 | setReviewSelectedSectionId(reviewCache.threadKey, sectionId); | ||
| 238 | } | ||
| 239 | }, | ||
| 240 | [reviewCache.threadKey], | ||
| 241 | ); | ||
| 242 | |||
| 243 | return { | ||
| 244 | error, | ||
| 245 | loadingGitDiffs, | ||
| 246 | loadingTurnIds, | ||
| 247 | reviewSections, | ||
| 248 | selectedSection, | ||
| 249 | refreshSelectedSection, | ||
| 250 | selectSection, | ||
| 251 | }; | ||
| 252 | } | ||
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx created+271| ... | @@ -0,0 +1,271 @@ | ||
| 1 | import { memo, useCallback, useEffect, useRef } from "react"; | ||
| 2 | import { | ||
| 3 | Pressable, | ||
| 4 | ScrollView, | ||
| 5 | TextInput, | ||
| 6 | View, | ||
| 7 | type LayoutChangeEvent, | ||
| 8 | type NativeSyntheticEvent, | ||
| 9 | type ViewProps, | ||
| 10 | useColorScheme, | ||
| 11 | } from "react-native"; | ||
| 12 | |||
| 13 | import { AppText as Text } from "../../components/AppText"; | ||
| 14 | import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule"; | ||
| 15 | import { | ||
| 16 | buildGhosttyThemeConfig, | ||
| 17 | getPierreTerminalTheme, | ||
| 18 | type TerminalTheme, | ||
| 19 | } from "./terminalTheme"; | ||
| 20 | import { terminalDebugLog } from "./terminalDebugLog"; | ||
| 21 | |||
| 22 | interface TerminalInputEvent { | ||
| 23 | readonly data: string; | ||
| 24 | } | ||
| 25 | |||
| 26 | interface TerminalResizeEvent { | ||
| 27 | readonly cols: number; | ||
| 28 | readonly rows: number; | ||
| 29 | } | ||
| 30 | |||
| 31 | interface TerminalSurfaceProps extends ViewProps { | ||
| 32 | readonly terminalKey: string; | ||
| 33 | readonly buffer: string; | ||
| 34 | readonly fontSize?: number; | ||
| 35 | readonly isRunning: boolean; | ||
| 36 | readonly keyboardFocusRequest?: number; | ||
| 37 | readonly theme?: TerminalTheme; | ||
| 38 | readonly onInput: (data: string) => void; | ||
| 39 | readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void; | ||
| 40 | } | ||
| 41 | |||
| 42 | function estimateGridSize(input: { | ||
| 43 | readonly width: number; | ||
| 44 | readonly height: number; | ||
| 45 | readonly fontSize: number; | ||
| 46 | }): { readonly cols: number; readonly rows: number } { | ||
| 47 | const cellWidth = input.fontSize * 0.62; | ||
| 48 | const cellHeight = input.fontSize * 1.35; | ||
| 49 | return { | ||
| 50 | cols: Math.max(20, Math.min(400, Math.floor(input.width / cellWidth))), | ||
| 51 | rows: Math.max(5, Math.min(200, Math.floor(input.height / cellHeight))), | ||
| 52 | }; | ||
| 53 | } | ||
| 54 | |||
| 55 | const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { | ||
| 56 | const fontSize = props.fontSize ?? 12; | ||
| 57 | const inputRef = useRef<TextInput>(null); | ||
| 58 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; | ||
| 59 | const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); | ||
| 60 | const statusLabel = props.isRunning | ||
| 61 | ? "Native terminal unavailable. Using text fallback." | ||
| 62 | : "Open terminal to start a shell."; | ||
| 63 | |||
| 64 | const handleLayout = (event: LayoutChangeEvent) => { | ||
| 65 | const { width, height } = event.nativeEvent.layout; | ||
| 66 | props.onResize(estimateGridSize({ width, height, fontSize })); | ||
| 67 | }; | ||
| 68 | |||
| 69 | useEffect(() => { | ||
| 70 | if ((props.keyboardFocusRequest ?? 0) > 0) { | ||
| 71 | inputRef.current?.blur(); | ||
| 72 | const focusFrame = requestAnimationFrame(() => inputRef.current?.focus()); | ||
| 73 | return () => cancelAnimationFrame(focusFrame); | ||
| 74 | } | ||
| 75 | |||
| 76 | return undefined; | ||
| 77 | }, [props.keyboardFocusRequest]); | ||
| 78 | |||
| 79 | return ( | ||
| 80 | <View | ||
| 81 | style={[ | ||
| 82 | { | ||
| 83 | flex: 1, | ||
| 84 | backgroundColor: theme.background, | ||
| 85 | borderRadius: 8, | ||
| 86 | overflow: "hidden", | ||
| 87 | }, | ||
| 88 | props.style, | ||
| 89 | ]} | ||
| 90 | onLayout={handleLayout} | ||
| 91 | > | ||
| 92 | <View style={{ flex: 1, paddingHorizontal: 10, paddingVertical: 8 }}> | ||
| 93 | <Text | ||
| 94 | style={{ | ||
| 95 | color: theme.mutedForeground, | ||
| 96 | fontSize: 11, | ||
| 97 | paddingBottom: 8, | ||
| 98 | }} | ||
| 99 | > | ||
| 100 | {statusLabel} | ||
| 101 | </Text> | ||
| 102 | <ScrollView | ||
| 103 | style={{ flex: 1 }} | ||
| 104 | contentContainerStyle={{ paddingBottom: 12 }} | ||
| 105 | showsVerticalScrollIndicator={false} | ||
| 106 | > | ||
| 107 | <Text | ||
| 108 | selectable | ||
| 109 | style={{ | ||
| 110 | color: theme.foreground, | ||
| 111 | fontFamily: "Menlo", | ||
| 112 | fontSize, | ||
| 113 | lineHeight: Math.round(fontSize * 1.35), | ||
| 114 | }} | ||
| 115 | > | ||
| 116 | {props.buffer || "$ "} | ||
| 117 | </Text> | ||
| 118 | </ScrollView> | ||
| 119 | </View> | ||
| 120 | <View | ||
| 121 | style={{ | ||
| 122 | borderTopWidth: 1, | ||
| 123 | borderTopColor: theme.border, | ||
| 124 | flexDirection: "row", | ||
| 125 | alignItems: "center", | ||
| 126 | gap: 8, | ||
| 127 | padding: 8, | ||
| 128 | }} | ||
| 129 | > | ||
| 130 | <TextInput | ||
| 131 | ref={inputRef} | ||
| 132 | autoCapitalize="none" | ||
| 133 | autoCorrect={false} | ||
| 134 | blurOnSubmit={false} | ||
| 135 | editable={props.isRunning} | ||
| 136 | placeholder="type and press return" | ||
| 137 | placeholderTextColor={theme.mutedForeground} | ||
| 138 | returnKeyType="send" | ||
| 139 | style={{ | ||
| 140 | color: theme.foreground, | ||
| 141 | flex: 1, | ||
| 142 | fontFamily: "Menlo", | ||
| 143 | fontSize: 13, | ||
| 144 | padding: 0, | ||
| 145 | }} | ||
| 146 | onSubmitEditing={(event) => { | ||
| 147 | const text = event.nativeEvent.text; | ||
| 148 | if (text.length > 0) { | ||
| 149 | props.onInput(`${text}\n`); | ||
| 150 | } | ||
| 151 | }} | ||
| 152 | /> | ||
| 153 | <Pressable | ||
| 154 | disabled={!props.isRunning} | ||
| 155 | style={({ pressed }) => ({ | ||
| 156 | opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1, | ||
| 157 | paddingHorizontal: 10, | ||
| 158 | paddingVertical: 6, | ||
| 159 | borderRadius: 8, | ||
| 160 | backgroundColor: theme.border, | ||
| 161 | })} | ||
| 162 | onPress={() => props.onInput("\u0003")} | ||
| 163 | > | ||
| 164 | <Text | ||
| 165 | style={{ | ||
| 166 | color: theme.foreground, | ||
| 167 | fontFamily: "DMSans_700Bold", | ||
| 168 | fontSize: 11, | ||
| 169 | }} | ||
| 170 | > | ||
| 171 | Ctrl-C | ||
| 172 | </Text> | ||
| 173 | </Pressable> | ||
| 174 | </View> | ||
| 175 | </View> | ||
| 176 | ); | ||
| 177 | }); | ||
| 178 | |||
| 179 | export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { | ||
| 180 | const fontSize = props.fontSize ?? 12; | ||
| 181 | const keyboardInputRef = useRef<TextInput>(null); | ||
| 182 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; | ||
| 183 | const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); | ||
| 184 | const { onInput, onResize } = props; | ||
| 185 | const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); | ||
| 186 | const hasNativeSurface = Boolean(NativeTerminalSurfaceView); | ||
| 187 | |||
| 188 | useEffect(() => { | ||
| 189 | terminalDebugLog("native:surface", { | ||
| 190 | terminalKey: props.terminalKey, | ||
| 191 | native: hasNativeSurface, | ||
| 192 | bufferLen: props.buffer.length, | ||
| 193 | isRunning: props.isRunning, | ||
| 194 | }); | ||
| 195 | }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); | ||
| 196 | const handleNativeInput = useCallback( | ||
| 197 | (event: NativeSyntheticEvent<TerminalInputEvent>) => { | ||
| 198 | onInput(event.nativeEvent.data); | ||
| 199 | }, | ||
| 200 | [onInput], | ||
| 201 | ); | ||
| 202 | const handleNativeResize = useCallback( | ||
| 203 | (event: NativeSyntheticEvent<TerminalResizeEvent>) => { | ||
| 204 | onResize({ | ||
| 205 | cols: event.nativeEvent.cols, | ||
| 206 | rows: event.nativeEvent.rows, | ||
| 207 | }); | ||
| 208 | }, | ||
| 209 | [onResize], | ||
| 210 | ); | ||
| 211 | |||
| 212 | // Reopen focus through React and forward input normally; this avoids a native focus-command bridge. | ||
| 213 | useEffect(() => { | ||
| 214 | if (!NativeTerminalSurfaceView || (props.keyboardFocusRequest ?? 0) <= 0) { | ||
| 215 | return undefined; | ||
| 216 | } | ||
| 217 | |||
| 218 | keyboardInputRef.current?.blur(); | ||
| 219 | const focusFrame = requestAnimationFrame(() => keyboardInputRef.current?.focus()); | ||
| 220 | return () => cancelAnimationFrame(focusFrame); | ||
| 221 | }, [NativeTerminalSurfaceView, props.keyboardFocusRequest]); | ||
| 222 | |||
| 223 | const handleKeyboardInput = useCallback( | ||
| 224 | (data: string) => { | ||
| 225 | if (data.length > 0) { | ||
| 226 | onInput(data); | ||
| 227 | keyboardInputRef.current?.clear(); | ||
| 228 | } | ||
| 229 | }, | ||
| 230 | [onInput], | ||
| 231 | ); | ||
| 232 | |||
| 233 | if (NativeTerminalSurfaceView) { | ||
| 234 | return ( | ||
| 235 | <View style={props.style}> | ||
| 236 | <NativeTerminalSurfaceView | ||
| 237 | appearanceScheme={appearanceScheme} | ||
| 238 | backgroundColor={theme.background} | ||
| 239 | foregroundColor={theme.foreground} | ||
| 240 | mutedForegroundColor={theme.mutedForeground} | ||
| 241 | terminalKey={props.terminalKey} | ||
| 242 | initialBuffer={props.buffer} | ||
| 243 | fontSize={fontSize} | ||
| 244 | style={{ flex: 1 }} | ||
| 245 | themeConfig={buildGhosttyThemeConfig(theme)} | ||
| 246 | onInput={handleNativeInput} | ||
| 247 | onResize={handleNativeResize} | ||
| 248 | /> | ||
| 249 | <TextInput | ||
| 250 | ref={keyboardInputRef} | ||
| 251 | autoCapitalize="none" | ||
| 252 | autoCorrect={false} | ||
| 253 | blurOnSubmit={false} | ||
| 254 | caretHidden | ||
| 255 | editable={props.isRunning} | ||
| 256 | keyboardType="ascii-capable" | ||
| 257 | style={{ bottom: 0, height: 1, left: 0, opacity: 0.01, position: "absolute", width: 1 }} | ||
| 258 | onChangeText={handleKeyboardInput} | ||
| 259 | onKeyPress={(event) => { | ||
| 260 | if (event.nativeEvent.key === "Backspace") { | ||
| 261 | onInput("\u007f"); | ||
| 262 | } | ||
| 263 | }} | ||
| 264 | onSubmitEditing={() => onInput("\n")} | ||
| 265 | /> | ||
| 266 | </View> | ||
| 267 | ); | ||
| 268 | } | ||
| 269 | |||
| 270 | return <FallbackTerminalSurface {...props} fontSize={fontSize} theme={theme} />; | ||
| 271 | }); | ||
apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx created+175| ... | @@ -0,0 +1,175 @@ | ||
| 1 | import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import { memo, useCallback, useEffect, useRef, useState } from "react"; | ||
| 4 | import { Pressable, View } from "react-native"; | ||
| 5 | |||
| 6 | import { AppText as Text } from "../../components/AppText"; | ||
| 7 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | ||
| 8 | import { | ||
| 9 | attachTerminalSession, | ||
| 10 | useTerminalSession, | ||
| 11 | useTerminalSessionTarget, | ||
| 12 | } from "../../state/use-terminal-session"; | ||
| 13 | import { TerminalSurface } from "./NativeTerminalSurface"; | ||
| 14 | import { hasNativeTerminalSurface } from "./nativeTerminalModule"; | ||
| 15 | import { terminalDebugLog } from "./terminalDebugLog"; | ||
| 16 | |||
| 17 | interface ThreadTerminalPanelProps { | ||
| 18 | readonly environmentId: EnvironmentId; | ||
| 19 | readonly threadId: ThreadId; | ||
| 20 | readonly cwd: string; | ||
| 21 | readonly worktreePath: string | null; | ||
| 22 | readonly visible: boolean; | ||
| 23 | readonly onClose: () => void; | ||
| 24 | } | ||
| 25 | |||
| 26 | const DEFAULT_TERMINAL_COLS = 80; | ||
| 27 | const DEFAULT_TERMINAL_ROWS = 24; | ||
| 28 | |||
| 29 | export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( | ||
| 30 | props: ThreadTerminalPanelProps, | ||
| 31 | ) { | ||
| 32 | const nativeTerminalAvailable = hasNativeTerminalSurface(); | ||
| 33 | const terminalId = DEFAULT_TERMINAL_ID; | ||
| 34 | const target = useTerminalSessionTarget({ | ||
| 35 | environmentId: props.environmentId, | ||
| 36 | threadId: props.threadId, | ||
| 37 | terminalId, | ||
| 38 | }); | ||
| 39 | const terminal = useTerminalSession(target); | ||
| 40 | const [lastGridSize, setLastGridSize] = useState({ | ||
| 41 | cols: DEFAULT_TERMINAL_COLS, | ||
| 42 | rows: DEFAULT_TERMINAL_ROWS, | ||
| 43 | }); | ||
| 44 | const lastGridSizeRef = useRef(lastGridSize); | ||
| 45 | lastGridSizeRef.current = lastGridSize; | ||
| 46 | |||
| 47 | const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; | ||
| 48 | const isRunning = terminal.status === "running" || terminal.status === "starting"; | ||
| 49 | |||
| 50 | useEffect(() => { | ||
| 51 | if (!props.visible) { | ||
| 52 | return; | ||
| 53 | } | ||
| 54 | |||
| 55 | const client = getEnvironmentClient(props.environmentId); | ||
| 56 | if (!client) { | ||
| 57 | terminalDebugLog("panel:attach-skip", { | ||
| 58 | reason: "no-environment-client", | ||
| 59 | environmentId: props.environmentId, | ||
| 60 | }); | ||
| 61 | return; | ||
| 62 | } | ||
| 63 | |||
| 64 | terminalDebugLog("panel:attach", { | ||
| 65 | environmentId: props.environmentId, | ||
| 66 | threadId: props.threadId, | ||
| 67 | terminalId, | ||
| 68 | }); | ||
| 69 | |||
| 70 | return attachTerminalSession({ | ||
| 71 | environmentId: props.environmentId, | ||
| 72 | client, | ||
| 73 | terminal: { | ||
| 74 | threadId: props.threadId, | ||
| 75 | terminalId, | ||
| 76 | cwd: props.cwd, | ||
| 77 | worktreePath: props.worktreePath, | ||
| 78 | cols: lastGridSizeRef.current.cols, | ||
| 79 | rows: lastGridSizeRef.current.rows, | ||
| 80 | }, | ||
| 81 | }); | ||
| 82 | }, [ | ||
| 83 | props.cwd, | ||
| 84 | props.environmentId, | ||
| 85 | props.threadId, | ||
| 86 | props.worktreePath, | ||
| 87 | props.visible, | ||
| 88 | terminalId, | ||
| 89 | ]); | ||
| 90 | |||
| 91 | const handleInput = useCallback( | ||
| 92 | (data: string) => { | ||
| 93 | const client = getEnvironmentClient(props.environmentId); | ||
| 94 | if (!client || !isRunning) { | ||
| 95 | return; | ||
| 96 | } | ||
| 97 | |||
| 98 | void client.terminal.write({ | ||
| 99 | threadId: props.threadId, | ||
| 100 | terminalId, | ||
| 101 | data, | ||
| 102 | }); | ||
| 103 | }, | ||
| 104 | [isRunning, props.environmentId, props.threadId, terminalId], | ||
| 105 | ); | ||
| 106 | |||
| 107 | const handleResize = useCallback( | ||
| 108 | (size: { readonly cols: number; readonly rows: number }) => { | ||
| 109 | if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) { | ||
| 110 | return; | ||
| 111 | } | ||
| 112 | |||
| 113 | setLastGridSize(size); | ||
| 114 | const client = getEnvironmentClient(props.environmentId); | ||
| 115 | if (!client || !isRunning) { | ||
| 116 | return; | ||
| 117 | } | ||
| 118 | |||
| 119 | void client.terminal.resize({ | ||
| 120 | threadId: props.threadId, | ||
| 121 | terminalId, | ||
| 122 | cols: size.cols, | ||
| 123 | rows: size.rows, | ||
| 124 | }); | ||
| 125 | }, | ||
| 126 | [ | ||
| 127 | isRunning, | ||
| 128 | lastGridSize.cols, | ||
| 129 | lastGridSize.rows, | ||
| 130 | props.environmentId, | ||
| 131 | props.threadId, | ||
| 132 | terminalId, | ||
| 133 | ], | ||
| 134 | ); | ||
| 135 | |||
| 136 | if (!props.visible) { | ||
| 137 | return null; | ||
| 138 | } | ||
| 139 | |||
| 140 | return ( | ||
| 141 | <View className="absolute inset-x-3 bottom-28 top-28 overflow-hidden rounded-[8px] border border-white/10 bg-neutral-950 shadow-2xl"> | ||
| 142 | <View className="flex-row items-center justify-between border-b border-white/10 px-3 py-2"> | ||
| 143 | <View className="min-w-0 flex-1"> | ||
| 144 | <Text className="font-t3-bold text-[13px] text-neutral-100" numberOfLines={1}> | ||
| 145 | Terminal | ||
| 146 | </Text> | ||
| 147 | <Text className="text-[11px] text-neutral-500" numberOfLines={1}> | ||
| 148 | {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"} | ||
| 149 | </Text> | ||
| 150 | </View> | ||
| 151 | <View className="flex-row items-center gap-2"> | ||
| 152 | {terminal.error ? ( | ||
| 153 | <Text className="max-w-44 text-right text-[11px] text-red-300" numberOfLines={1}> | ||
| 154 | {terminal.error} | ||
| 155 | </Text> | ||
| 156 | ) : null} | ||
| 157 | <Pressable | ||
| 158 | className="h-8 w-8 items-center justify-center rounded-[8px] bg-white/10" | ||
| 159 | onPress={props.onClose} | ||
| 160 | > | ||
| 161 | <SymbolView name="xmark" size={13} tintColor="#e5e5e5" type="monochrome" /> | ||
| 162 | </Pressable> | ||
| 163 | </View> | ||
| 164 | </View> | ||
| 165 | <TerminalSurface | ||
| 166 | terminalKey={terminalKey} | ||
| 167 | buffer={terminal.buffer} | ||
| 168 | isRunning={isRunning} | ||
| 169 | onInput={handleInput} | ||
| 170 | onResize={handleResize} | ||
| 171 | style={{ flex: 1 }} | ||
| 172 | /> | ||
| 173 | </View> | ||
| 174 | ); | ||
| 175 | }); | ||
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.test.ts created+108| ... | @@ -0,0 +1,108 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap"; | ||
| 4 | |||
| 5 | describe("resolveTerminalRouteBootstrap", () => { | ||
| 6 | it("redirects bare terminal routes to another already-running terminal for the thread", () => { | ||
| 7 | expect( | ||
| 8 | resolveTerminalRouteBootstrap({ | ||
| 9 | hasThread: true, | ||
| 10 | hasWorkspaceRoot: true, | ||
| 11 | hasOpened: false, | ||
| 12 | requestedTerminalId: null, | ||
| 13 | currentTerminalId: "default", | ||
| 14 | runningTerminalId: "term-2", | ||
| 15 | currentTerminalStatus: "closed", | ||
| 16 | hasCurrentTerminalHydration: false, | ||
| 17 | }), | ||
| 18 | ).toEqual({ | ||
| 19 | kind: "redirect", | ||
| 20 | terminalId: "term-2", | ||
| 21 | }); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("hydrates the current running terminal when client state is not hydrated yet", () => { | ||
| 25 | expect( | ||
| 26 | resolveTerminalRouteBootstrap({ | ||
| 27 | hasThread: true, | ||
| 28 | hasWorkspaceRoot: true, | ||
| 29 | hasOpened: false, | ||
| 30 | requestedTerminalId: null, | ||
| 31 | currentTerminalId: "default", | ||
| 32 | runningTerminalId: "default", | ||
| 33 | currentTerminalStatus: "running", | ||
| 34 | hasCurrentTerminalHydration: false, | ||
| 35 | }), | ||
| 36 | ).toEqual({ | ||
| 37 | kind: "open", | ||
| 38 | }); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("opens explicit terminal routes when the session still needs hydration", () => { | ||
| 42 | expect( | ||
| 43 | resolveTerminalRouteBootstrap({ | ||
| 44 | hasThread: true, | ||
| 45 | hasWorkspaceRoot: true, | ||
| 46 | hasOpened: false, | ||
| 47 | requestedTerminalId: "term-2", | ||
| 48 | currentTerminalId: "term-2", | ||
| 49 | runningTerminalId: "term-2", | ||
| 50 | currentTerminalStatus: "running", | ||
| 51 | hasCurrentTerminalHydration: false, | ||
| 52 | }), | ||
| 53 | ).toEqual({ | ||
| 54 | kind: "open", | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | |||
| 58 | it("stays idle after the route already bootstrapped once", () => { | ||
| 59 | expect( | ||
| 60 | resolveTerminalRouteBootstrap({ | ||
| 61 | hasThread: true, | ||
| 62 | hasWorkspaceRoot: true, | ||
| 63 | hasOpened: true, | ||
| 64 | requestedTerminalId: null, | ||
| 65 | currentTerminalId: "default", | ||
| 66 | runningTerminalId: "default", | ||
| 67 | currentTerminalStatus: "running", | ||
| 68 | hasCurrentTerminalHydration: true, | ||
| 69 | }), | ||
| 70 | ).toEqual({ | ||
| 71 | kind: "idle", | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("stays idle when the current running terminal is already hydrated in client state", () => { | ||
| 76 | expect( | ||
| 77 | resolveTerminalRouteBootstrap({ | ||
| 78 | hasThread: true, | ||
| 79 | hasWorkspaceRoot: true, | ||
| 80 | hasOpened: false, | ||
| 81 | requestedTerminalId: null, | ||
| 82 | currentTerminalId: "default", | ||
| 83 | runningTerminalId: "default", | ||
| 84 | currentTerminalStatus: "running", | ||
| 85 | hasCurrentTerminalHydration: true, | ||
| 86 | }), | ||
| 87 | ).toEqual({ | ||
| 88 | kind: "idle", | ||
| 89 | }); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("stays idle for explicit running terminal routes that already have hydrated output", () => { | ||
| 93 | expect( | ||
| 94 | resolveTerminalRouteBootstrap({ | ||
| 95 | hasThread: true, | ||
| 96 | hasWorkspaceRoot: true, | ||
| 97 | hasOpened: false, | ||
| 98 | requestedTerminalId: "term-2", | ||
| 99 | currentTerminalId: "term-2", | ||
| 100 | runningTerminalId: "term-2", | ||
| 101 | currentTerminalStatus: "running", | ||
| 102 | hasCurrentTerminalHydration: true, | ||
| 103 | }), | ||
| 104 | ).toEqual({ | ||
| 105 | kind: "idle", | ||
| 106 | }); | ||
| 107 | }); | ||
| 108 | }); | ||
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx created+1334| ... | @@ -0,0 +1,1334 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_TERMINAL_ID, | ||
| 3 | EnvironmentId, | ||
| 4 | type TerminalAttachStreamEvent, | ||
| 5 | ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; | ||
| 8 | import { SymbolView } from "expo-symbols"; | ||
| 9 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | ||
| 10 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 11 | import { | ||
| 12 | Pressable, | ||
| 13 | ScrollView, | ||
| 14 | Text as RNText, | ||
| 15 | View, | ||
| 16 | useColorScheme, | ||
| 17 | type LayoutChangeEvent, | ||
| 18 | type NativeScrollEvent, | ||
| 19 | type NativeSyntheticEvent, | ||
| 20 | } from "react-native"; | ||
| 21 | import { | ||
| 22 | KeyboardController, | ||
| 23 | KeyboardEvents, | ||
| 24 | KeyboardStickyView, | ||
| 25 | useKeyboardState, | ||
| 26 | } from "react-native-keyboard-controller"; | ||
| 27 | |||
| 28 | import { EmptyState } from "../../components/EmptyState"; | ||
| 29 | import { GlassSurface } from "../../components/GlassSurface"; | ||
| 30 | import { LoadingScreen } from "../../components/LoadingScreen"; | ||
| 31 | import { buildThreadTerminalNavigation } from "../../lib/routes"; | ||
| 32 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | ||
| 33 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | ||
| 34 | import { | ||
| 35 | attachTerminalSession, | ||
| 36 | useKnownTerminalSessions, | ||
| 37 | useTerminalSession, | ||
| 38 | useTerminalSessionTarget, | ||
| 39 | } from "../../state/use-terminal-session"; | ||
| 40 | import { useThreadSelection } from "../../state/use-thread-selection"; | ||
| 41 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | ||
| 42 | import { TerminalSurface } from "./NativeTerminalSurface"; | ||
| 43 | import { getPierreTerminalTheme } from "./terminalTheme"; | ||
| 44 | import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; | ||
| 45 | import { terminalDebugLog } from "./terminalDebugLog"; | ||
| 46 | import { | ||
| 47 | getTerminalBufferReplayKey, | ||
| 48 | getTerminalSurfaceReplayBuffer, | ||
| 49 | TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, | ||
| 50 | } from "./terminalBufferReplay"; | ||
| 51 | import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap"; | ||
| 52 | import { | ||
| 53 | resolveTerminalOpenLocation, | ||
| 54 | stagePendingTerminalLaunch, | ||
| 55 | takePendingTerminalLaunch, | ||
| 56 | } from "./terminalLaunchContext"; | ||
| 57 | import { | ||
| 58 | basename, | ||
| 59 | buildTerminalMenuSessions, | ||
| 60 | getTerminalStatusLabel, | ||
| 61 | nextOpenTerminalId, | ||
| 62 | resolveTerminalSessionLabel, | ||
| 63 | type TerminalMenuSession, | ||
| 64 | } from "./terminalMenu"; | ||
| 65 | import { | ||
| 66 | DEFAULT_TERMINAL_FONT_SIZE, | ||
| 67 | MAX_TERMINAL_FONT_SIZE, | ||
| 68 | MIN_TERMINAL_FONT_SIZE, | ||
| 69 | TERMINAL_FONT_SIZE_STEP, | ||
| 70 | normalizeTerminalFontSize, | ||
| 71 | } from "./terminalPreferences"; | ||
| 72 | import { | ||
| 73 | cacheTerminalFontSize, | ||
| 74 | cacheTerminalGridSize, | ||
| 75 | getCachedTerminalFontSize, | ||
| 76 | getCachedTerminalGridSize, | ||
| 77 | } from "./terminalUiState"; | ||
| 78 | |||
| 79 | const DEFAULT_TERMINAL_COLS = 80; | ||
| 80 | const DEFAULT_TERMINAL_ROWS = 24; | ||
| 81 | const TERMINAL_ACCESSORY_HEIGHT = 52; | ||
| 82 | const TERMINAL_ACCESSORY_FADE_WIDTH = 18; | ||
| 83 | |||
| 84 | type PendingModifier = "ctrl" | "meta"; | ||
| 85 | type HostPlatform = "mac" | "linux" | "windows" | "unknown"; | ||
| 86 | |||
| 87 | type TerminalToolbarAction = | ||
| 88 | | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string } | ||
| 89 | | { readonly kind: "clear"; readonly key: string; readonly label: string } | ||
| 90 | | { | ||
| 91 | readonly kind: "modifier"; | ||
| 92 | readonly key: string; | ||
| 93 | readonly label: string; | ||
| 94 | readonly modifier: PendingModifier; | ||
| 95 | }; | ||
| 96 | |||
| 97 | function getTerminalStatusTone(input: { | ||
| 98 | readonly status: TerminalMenuSession["status"]; | ||
| 99 | readonly hasRunningSubprocess?: boolean; | ||
| 100 | }): { | ||
| 101 | readonly tintColor: string; | ||
| 102 | readonly textColor: string; | ||
| 103 | } { | ||
| 104 | if (input.status === "running") { | ||
| 105 | if (input.hasRunningSubprocess) { | ||
| 106 | return { | ||
| 107 | tintColor: "#fbbf24", | ||
| 108 | textColor: "#a3a3a3", | ||
| 109 | }; | ||
| 110 | } | ||
| 111 | |||
| 112 | return { | ||
| 113 | tintColor: "#34d399", | ||
| 114 | textColor: "#a3a3a3", | ||
| 115 | }; | ||
| 116 | } | ||
| 117 | |||
| 118 | if (input.status === "starting") { | ||
| 119 | return { | ||
| 120 | tintColor: "#f59e0b", | ||
| 121 | textColor: "#a3a3a3", | ||
| 122 | }; | ||
| 123 | } | ||
| 124 | |||
| 125 | if (input.status === "error") { | ||
| 126 | return { | ||
| 127 | tintColor: "#ef4444", | ||
| 128 | textColor: "#fca5a5", | ||
| 129 | }; | ||
| 130 | } | ||
| 131 | |||
| 132 | return { | ||
| 133 | tintColor: "#ef4444", | ||
| 134 | textColor: "#a3a3a3", | ||
| 135 | }; | ||
| 136 | } | ||
| 137 | |||
| 138 | function firstRouteParam(value: string | string[] | undefined): string | null { | ||
| 139 | if (Array.isArray(value)) { | ||
| 140 | return value[0] ?? null; | ||
| 141 | } | ||
| 142 | |||
| 143 | return value ?? null; | ||
| 144 | } | ||
| 145 | |||
| 146 | function inferHostPlatform(environmentLabel: string | null): HostPlatform { | ||
| 147 | const value = environmentLabel?.toLowerCase() ?? ""; | ||
| 148 | if ( | ||
| 149 | value.includes("mac") || | ||
| 150 | value.includes("macbook") || | ||
| 151 | value.includes("mac mini") || | ||
| 152 | value.includes("imac") || | ||
| 153 | value.includes("darwin") | ||
| 154 | ) { | ||
| 155 | return "mac"; | ||
| 156 | } | ||
| 157 | if (value.includes("windows") || value.includes("win")) { | ||
| 158 | return "windows"; | ||
| 159 | } | ||
| 160 | if (value.includes("linux") || value.includes("ubuntu") || value.includes("debian")) { | ||
| 161 | return "linux"; | ||
| 162 | } | ||
| 163 | |||
| 164 | return "unknown"; | ||
| 165 | } | ||
| 166 | |||
| 167 | function applyCtrlModifier(input: string): string { | ||
| 168 | const firstCharacter = input[0]; | ||
| 169 | if (!firstCharacter) { | ||
| 170 | return input; | ||
| 171 | } | ||
| 172 | |||
| 173 | const lowerCharacter = firstCharacter.toLowerCase(); | ||
| 174 | if (lowerCharacter >= "a" && lowerCharacter <= "z") { | ||
| 175 | return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); | ||
| 176 | } | ||
| 177 | |||
| 178 | if (firstCharacter === "@") return "\u0000"; | ||
| 179 | if (firstCharacter === "[") return "\u001b"; | ||
| 180 | if (firstCharacter === "\\") return "\u001c"; | ||
| 181 | if (firstCharacter === "]") return "\u001d"; | ||
| 182 | if (firstCharacter === "^") return "\u001e"; | ||
| 183 | if (firstCharacter === "_") return "\u001f"; | ||
| 184 | if (firstCharacter === "?") return "\u007f"; | ||
| 185 | |||
| 186 | return input; | ||
| 187 | } | ||
| 188 | |||
| 189 | function withAlpha(hexColor: string, alpha: string): string { | ||
| 190 | return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor; | ||
| 191 | } | ||
| 192 | |||
| 193 | function pickRunningTerminalSessionForBootstrap( | ||
| 194 | sessions: ReadonlyArray<KnownTerminalSession>, | ||
| 195 | ): KnownTerminalSession | null { | ||
| 196 | const running = sessions.filter( | ||
| 197 | (session) => session.state.status === "running" || session.state.status === "starting", | ||
| 198 | ); | ||
| 199 | if (running.length === 0) { | ||
| 200 | return null; | ||
| 201 | } | ||
| 202 | return ( | ||
| 203 | running.find((session) => session.target.terminalId === DEFAULT_TERMINAL_ID) ?? | ||
| 204 | running[0] ?? | ||
| 205 | null | ||
| 206 | ); | ||
| 207 | } | ||
| 208 | |||
| 209 | export function ThreadTerminalRouteScreen() { | ||
| 210 | const router = useRouter(); | ||
| 211 | const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; | ||
| 212 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); | ||
| 213 | const params = useLocalSearchParams<{ | ||
| 214 | environmentId?: string | string[]; | ||
| 215 | threadId?: string | string[]; | ||
| 216 | terminalId?: string | string[]; | ||
| 217 | }>(); | ||
| 218 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = | ||
| 219 | useThreadSelection(); | ||
| 220 | const selectedThreadDetail = useSelectedThreadDetail(); | ||
| 221 | const routeEnvironmentIdRaw = firstRouteParam(params.environmentId); | ||
| 222 | const routeThreadIdRaw = firstRouteParam(params.threadId); | ||
| 223 | const routeEnvironmentId = routeEnvironmentIdRaw | ||
| 224 | ? EnvironmentId.make(routeEnvironmentIdRaw) | ||
| 225 | : null; | ||
| 226 | const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null; | ||
| 227 | const requestedTerminalId = firstRouteParam(params.terminalId); | ||
| 228 | const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; | ||
| 229 | const cachedFontSize = getCachedTerminalFontSize(); | ||
| 230 | const cachedRouteGridSize = | ||
| 231 | routeEnvironmentId && routeThreadId | ||
| 232 | ? getCachedTerminalGridSize({ | ||
| 233 | environmentId: routeEnvironmentId, | ||
| 234 | threadId: routeThreadId, | ||
| 235 | terminalId, | ||
| 236 | }) | ||
| 237 | : null; | ||
| 238 | const knownSessions = useKnownTerminalSessions({ | ||
| 239 | environmentId: selectedThread?.environmentId ?? null, | ||
| 240 | threadId: selectedThread?.id ?? null, | ||
| 241 | }); | ||
| 242 | const [lastGridSize, setLastGridSize] = useState( | ||
| 243 | cachedRouteGridSize ?? { | ||
| 244 | cols: DEFAULT_TERMINAL_COLS, | ||
| 245 | rows: DEFAULT_TERMINAL_ROWS, | ||
| 246 | }, | ||
| 247 | ); | ||
| 248 | const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); | ||
| 249 | const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); | ||
| 250 | const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); | ||
| 251 | const toolbarScrollGeometryRef = useRef({ | ||
| 252 | contentWidth: 0, | ||
| 253 | offsetX: 0, | ||
| 254 | viewportWidth: 0, | ||
| 255 | }); | ||
| 256 | const [toolbarScrollEdges, setToolbarScrollEdges] = useState({ | ||
| 257 | showLeftFade: false, | ||
| 258 | showRightFade: false, | ||
| 259 | }); | ||
| 260 | const hasOpenedRef = useRef(false); | ||
| 261 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 262 | const attachStreamLogCountRef = useRef(0); | ||
| 263 | const firstNonEmptyBufferLoggedRef = useRef(false); | ||
| 264 | const lastBufferReplayKeyRef = useRef<string | null>(null); | ||
| 265 | const [readyBufferReplayKey, setReadyBufferReplayKey] = useState<string | null>(null); | ||
| 266 | const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( | ||
| 267 | cachedFontSize !== null, | ||
| 268 | ); | ||
| 269 | /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */ | ||
| 270 | const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true); | ||
| 271 | const [pendingModifierState, setPendingModifierState] = useState<{ | ||
| 272 | readonly terminalId: string; | ||
| 273 | readonly value: PendingModifier | null; | ||
| 274 | }>({ | ||
| 275 | terminalId, | ||
| 276 | value: null, | ||
| 277 | }); | ||
| 278 | const target = useTerminalSessionTarget({ | ||
| 279 | environmentId: selectedThread?.environmentId ?? null, | ||
| 280 | threadId: selectedThread?.id ?? null, | ||
| 281 | terminalId, | ||
| 282 | }); | ||
| 283 | const terminal = useTerminalSession(target); | ||
| 284 | const terminalKey = selectedThread | ||
| 285 | ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` | ||
| 286 | : terminalId; | ||
| 287 | const bufferReplayKey = useMemo( | ||
| 288 | () => getTerminalBufferReplayKey({ terminalKey, fontSize }), | ||
| 289 | [fontSize, terminalKey], | ||
| 290 | ); | ||
| 291 | if (lastBufferReplayKeyRef.current === null) { | ||
| 292 | lastBufferReplayKeyRef.current = bufferReplayKey; | ||
| 293 | } | ||
| 294 | const terminalSurfaceBuffer = getTerminalSurfaceReplayBuffer({ | ||
| 295 | buffer: terminal.buffer, | ||
| 296 | replayKey: bufferReplayKey, | ||
| 297 | readyReplayKey: readyBufferReplayKey, | ||
| 298 | }); | ||
| 299 | const isRunning = terminal.status === "running" || terminal.status === "starting"; | ||
| 300 | |||
| 301 | useEffect(() => { | ||
| 302 | terminalDebugLog("surface:props", { | ||
| 303 | terminalKey, | ||
| 304 | atomBufferLen: terminal.buffer.length, | ||
| 305 | surfaceBufferLen: terminalSurfaceBuffer.length, | ||
| 306 | replayKey: bufferReplayKey, | ||
| 307 | readyReplayKey: readyBufferReplayKey, | ||
| 308 | status: terminal.status, | ||
| 309 | version: terminal.version, | ||
| 310 | }); | ||
| 311 | }, [ | ||
| 312 | bufferReplayKey, | ||
| 313 | readyBufferReplayKey, | ||
| 314 | terminal.buffer.length, | ||
| 315 | terminal.status, | ||
| 316 | terminal.version, | ||
| 317 | terminalKey, | ||
| 318 | terminalSurfaceBuffer.length, | ||
| 319 | ]); | ||
| 320 | |||
| 321 | useEffect(() => { | ||
| 322 | terminalDebugLog("session:status", { | ||
| 323 | terminalKey, | ||
| 324 | status: terminal.status, | ||
| 325 | error: terminal.error, | ||
| 326 | summary: terminal.summary?.cwd ?? null, | ||
| 327 | bufferLen: terminal.buffer.length, | ||
| 328 | version: terminal.version, | ||
| 329 | }); | ||
| 330 | }, [ | ||
| 331 | terminal.buffer.length, | ||
| 332 | terminal.error, | ||
| 333 | terminal.status, | ||
| 334 | terminal.summary?.cwd, | ||
| 335 | terminal.version, | ||
| 336 | terminalKey, | ||
| 337 | ]); | ||
| 338 | |||
| 339 | useEffect(() => { | ||
| 340 | if (terminal.buffer.length === 0 || firstNonEmptyBufferLoggedRef.current) { | ||
| 341 | return; | ||
| 342 | } | ||
| 343 | firstNonEmptyBufferLoggedRef.current = true; | ||
| 344 | terminalDebugLog("session:first-nonempty-buffer", { | ||
| 345 | terminalKey, | ||
| 346 | length: terminal.buffer.length, | ||
| 347 | preview: terminal.buffer.slice(0, 160), | ||
| 348 | }); | ||
| 349 | }, [terminal.buffer, terminal.buffer.length, terminalKey]); | ||
| 350 | const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; | ||
| 351 | const hostPlatform = useMemo( | ||
| 352 | () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), | ||
| 353 | [selectedEnvironmentConnection?.environmentLabel], | ||
| 354 | ); | ||
| 355 | const runningSession = useMemo( | ||
| 356 | () => pickRunningTerminalSessionForBootstrap(knownSessions), | ||
| 357 | [knownSessions], | ||
| 358 | ); | ||
| 359 | const activeKnownSession = useMemo( | ||
| 360 | () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, | ||
| 361 | [knownSessions, terminalId], | ||
| 362 | ); | ||
| 363 | |||
| 364 | const terminalAttachLaunchHintsRef = useRef({ | ||
| 365 | terminalSummary: terminal.summary, | ||
| 366 | activeKnownSummary: activeKnownSession?.state.summary ?? null, | ||
| 367 | }); | ||
| 368 | terminalAttachLaunchHintsRef.current = { | ||
| 369 | terminalSummary: terminal.summary, | ||
| 370 | activeKnownSummary: activeKnownSession?.state.summary ?? null, | ||
| 371 | }; | ||
| 372 | |||
| 373 | const terminalStatusTone = useMemo( | ||
| 374 | () => | ||
| 375 | getTerminalStatusTone({ | ||
| 376 | status: terminal.status, | ||
| 377 | hasRunningSubprocess: terminal.hasRunningSubprocess, | ||
| 378 | }), | ||
| 379 | [terminal.hasRunningSubprocess, terminal.status], | ||
| 380 | ); | ||
| 381 | const terminalTheme = getPierreTerminalTheme(appearanceScheme); | ||
| 382 | const pendingModifier = | ||
| 383 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; | ||
| 384 | const headerTitle = useMemo(() => { | ||
| 385 | const topLineParts = [ | ||
| 386 | selectedEnvironmentConnection?.environmentLabel ?? null, | ||
| 387 | selectedThreadProject?.title ?? null, | ||
| 388 | ].filter((value): value is string => Boolean(value)); | ||
| 389 | |||
| 390 | return { | ||
| 391 | topLine: topLineParts.join(" \u00b7 "), | ||
| 392 | bottomLine: cwd ?? selectedThreadProject?.workspaceRoot ?? "", | ||
| 393 | }; | ||
| 394 | }, [ | ||
| 395 | cwd, | ||
| 396 | selectedEnvironmentConnection?.environmentLabel, | ||
| 397 | selectedThreadProject?.title, | ||
| 398 | selectedThreadProject?.workspaceRoot, | ||
| 399 | ]); | ||
| 400 | const terminalToolbarActions = useMemo<ReadonlyArray<TerminalToolbarAction>>(() => { | ||
| 401 | const modifierActions: ReadonlyArray<TerminalToolbarAction> = | ||
| 402 | hostPlatform === "mac" | ||
| 403 | ? [ | ||
| 404 | { kind: "modifier", key: "cmd", label: "cmd", modifier: "meta" }, | ||
| 405 | { kind: "modifier", key: "ctrl", label: "ctrl", modifier: "ctrl" }, | ||
| 406 | ] | ||
| 407 | : [ | ||
| 408 | { kind: "modifier", key: "ctrl", label: "ctrl", modifier: "ctrl" }, | ||
| 409 | { kind: "modifier", key: "alt", label: "alt", modifier: "meta" }, | ||
| 410 | ]; | ||
| 411 | |||
| 412 | return [ | ||
| 413 | { kind: "send", key: "esc", label: "esc", data: "\u001b" }, | ||
| 414 | ...modifierActions, | ||
| 415 | { kind: "send", key: "tab", label: "tab", data: "\t" }, | ||
| 416 | { kind: "clear", key: "clear", label: "clear" }, | ||
| 417 | { kind: "send", key: "up", label: "↑", data: "\u001b[A" }, | ||
| 418 | { kind: "send", key: "down", label: "↓", data: "\u001b[B" }, | ||
| 419 | { kind: "send", key: "left", label: "←", data: "\u001b[D" }, | ||
| 420 | { kind: "send", key: "right", label: "→", data: "\u001b[C" }, | ||
| 421 | { kind: "send", key: "tilde", label: "~", data: "~" }, | ||
| 422 | { kind: "send", key: "pipe", label: "|", data: "|" }, | ||
| 423 | { kind: "send", key: "slash", label: "/", data: "/" }, | ||
| 424 | { kind: "send", key: "dash", label: "-", data: "-" }, | ||
| 425 | ]; | ||
| 426 | }, [hostPlatform]); | ||
| 427 | const keyboardState = useKeyboardState((state) => ({ | ||
| 428 | height: state.height, | ||
| 429 | isVisible: state.isVisible, | ||
| 430 | })); | ||
| 431 | const isAccessoryVisible = keyboardState.isVisible && !isAccessoryDismissed; | ||
| 432 | const terminalBottomInset = | ||
| 433 | (keyboardState.isVisible ? keyboardState.height : 0) + | ||
| 434 | (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); | ||
| 435 | |||
| 436 | useEffect(() => { | ||
| 437 | const keyboardWillShow = KeyboardEvents.addListener("keyboardWillShow", () => { | ||
| 438 | setIsAccessoryDismissed(false); | ||
| 439 | }); | ||
| 440 | const keyboardWillHide = KeyboardEvents.addListener("keyboardWillHide", () => { | ||
| 441 | setIsAccessoryDismissed(true); | ||
| 442 | }); | ||
| 443 | |||
| 444 | return () => { | ||
| 445 | keyboardWillShow.remove(); | ||
| 446 | keyboardWillHide.remove(); | ||
| 447 | }; | ||
| 448 | }, []); | ||
| 449 | |||
| 450 | const terminalMenuSessions = useMemo<ReadonlyArray<TerminalMenuSession>>( | ||
| 451 | () => | ||
| 452 | buildTerminalMenuSessions({ | ||
| 453 | knownSessions, | ||
| 454 | workspaceRoot: selectedThreadProject?.workspaceRoot ?? null, | ||
| 455 | currentSession: { | ||
| 456 | terminalId, | ||
| 457 | cwd: cwd ?? null, | ||
| 458 | status: terminal.status, | ||
| 459 | hasRunningSubprocess: terminal.hasRunningSubprocess, | ||
| 460 | displayLabel: resolveTerminalSessionLabel(terminalId, terminal.summary), | ||
| 461 | updatedAt: terminal.updatedAt, | ||
| 462 | }, | ||
| 463 | }), | ||
| 464 | [ | ||
| 465 | cwd, | ||
| 466 | knownSessions, | ||
| 467 | selectedThreadProject?.workspaceRoot, | ||
| 468 | terminal.hasRunningSubprocess, | ||
| 469 | terminal.summary, | ||
| 470 | terminal.status, | ||
| 471 | terminal.updatedAt, | ||
| 472 | terminalId, | ||
| 473 | ], | ||
| 474 | ); | ||
| 475 | |||
| 476 | const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => { | ||
| 477 | const n = ++attachStreamLogCountRef.current; | ||
| 478 | if (event.type === "output" && n > 32 && n % 64 !== 0) { | ||
| 479 | return; | ||
| 480 | } | ||
| 481 | if (event.type === "snapshot") { | ||
| 482 | terminalDebugLog("attach:stream", { | ||
| 483 | n, | ||
| 484 | type: event.type, | ||
| 485 | status: event.snapshot.status, | ||
| 486 | historyLen: event.snapshot.history.length, | ||
| 487 | cwd: event.snapshot.cwd, | ||
| 488 | }); | ||
| 489 | return; | ||
| 490 | } | ||
| 491 | if (event.type === "output") { | ||
| 492 | terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length }); | ||
| 493 | return; | ||
| 494 | } | ||
| 495 | terminalDebugLog("attach:stream", { n, type: event.type }); | ||
| 496 | }, []); | ||
| 497 | |||
| 498 | const attachTerminal = useCallback(() => { | ||
| 499 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | ||
| 500 | terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" }); | ||
| 501 | return null; | ||
| 502 | } | ||
| 503 | |||
| 504 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 505 | if (!client) { | ||
| 506 | terminalDebugLog("attach:abort", { | ||
| 507 | reason: "no-environment-client", | ||
| 508 | environmentId: selectedThread.environmentId, | ||
| 509 | }); | ||
| 510 | return null; | ||
| 511 | } | ||
| 512 | |||
| 513 | const pendingLaunchTarget = { | ||
| 514 | environmentId: selectedThread.environmentId, | ||
| 515 | threadId: selectedThread.id, | ||
| 516 | terminalId, | ||
| 517 | }; | ||
| 518 | const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget); | ||
| 519 | let initialInputSent = false; | ||
| 520 | |||
| 521 | try { | ||
| 522 | const launchLocation = pendingLaunch | ||
| 523 | ? { | ||
| 524 | cwd: pendingLaunch.cwd, | ||
| 525 | worktreePath: pendingLaunch.worktreePath, | ||
| 526 | } | ||
| 527 | : resolveTerminalOpenLocation({ | ||
| 528 | terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary, | ||
| 529 | activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary, | ||
| 530 | workspaceRoot: selectedThreadProject.workspaceRoot, | ||
| 531 | threadShellWorktreePath: selectedThread.worktreePath ?? null, | ||
| 532 | threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, | ||
| 533 | }); | ||
| 534 | |||
| 535 | terminalDebugLog("attach:start", { | ||
| 536 | terminalId, | ||
| 537 | threadId: selectedThread.id, | ||
| 538 | cols: lastGridSize.cols, | ||
| 539 | rows: lastGridSize.rows, | ||
| 540 | cwd: launchLocation.cwd, | ||
| 541 | worktreePath: launchLocation.worktreePath, | ||
| 542 | }); | ||
| 543 | |||
| 544 | return attachTerminalSession({ | ||
| 545 | environmentId: selectedThread.environmentId, | ||
| 546 | client, | ||
| 547 | terminal: { | ||
| 548 | threadId: selectedThread.id, | ||
| 549 | terminalId, | ||
| 550 | cwd: launchLocation.cwd, | ||
| 551 | worktreePath: launchLocation.worktreePath, | ||
| 552 | cols: lastGridSize.cols, | ||
| 553 | rows: lastGridSize.rows, | ||
| 554 | env: pendingLaunch?.env, | ||
| 555 | ...(pendingLaunch ? { restartIfNotRunning: true } : {}), | ||
| 556 | }, | ||
| 557 | onEvent: logAttachStreamEvent, | ||
| 558 | onSnapshot: () => { | ||
| 559 | if (!pendingLaunch?.initialInput || initialInputSent) { | ||
| 560 | return; | ||
| 561 | } | ||
| 562 | |||
| 563 | initialInputSent = true; | ||
| 564 | void client.terminal.write({ | ||
| 565 | threadId: selectedThread.id, | ||
| 566 | terminalId, | ||
| 567 | data: pendingLaunch.initialInput, | ||
| 568 | }); | ||
| 569 | }, | ||
| 570 | }); | ||
| 571 | } catch (error) { | ||
| 572 | terminalDebugLog("attach:error", { | ||
| 573 | message: error instanceof Error ? error.message : String(error), | ||
| 574 | }); | ||
| 575 | if (pendingLaunch) { | ||
| 576 | stagePendingTerminalLaunch({ | ||
| 577 | target: pendingLaunchTarget, | ||
| 578 | launch: pendingLaunch, | ||
| 579 | }); | ||
| 580 | } | ||
| 581 | |||
| 582 | throw error; | ||
| 583 | } | ||
| 584 | }, [ | ||
| 585 | lastGridSize.cols, | ||
| 586 | lastGridSize.rows, | ||
| 587 | logAttachStreamEvent, | ||
| 588 | selectedThreadDetail?.worktreePath, | ||
| 589 | selectedThread, | ||
| 590 | selectedThreadProject?.workspaceRoot, | ||
| 591 | terminalId, | ||
| 592 | ]); | ||
| 593 | |||
| 594 | const attachTerminalRef = useRef(attachTerminal); | ||
| 595 | attachTerminalRef.current = attachTerminal; | ||
| 596 | const selectedThreadRef = useRef(selectedThread); | ||
| 597 | selectedThreadRef.current = selectedThread; | ||
| 598 | const selectedThreadProjectBootstrapRef = useRef(selectedThreadProject); | ||
| 599 | selectedThreadProjectBootstrapRef.current = selectedThreadProject; | ||
| 600 | const runningSessionRef = useRef(runningSession); | ||
| 601 | runningSessionRef.current = runningSession; | ||
| 602 | const terminalBootstrapRef = useRef({ | ||
| 603 | status: terminal.status, | ||
| 604 | bufferLen: terminal.buffer.length, | ||
| 605 | }); | ||
| 606 | terminalBootstrapRef.current = { | ||
| 607 | status: terminal.status, | ||
| 608 | bufferLen: terminal.buffer.length, | ||
| 609 | }; | ||
| 610 | |||
| 611 | useEffect(() => { | ||
| 612 | hasOpenedRef.current = false; | ||
| 613 | attachStreamLogCountRef.current = 0; | ||
| 614 | firstNonEmptyBufferLoggedRef.current = false; | ||
| 615 | }, [terminalKey]); | ||
| 616 | |||
| 617 | const clearBufferReplayTimer = useCallback(() => { | ||
| 618 | if (bufferReplayTimerRef.current !== null) { | ||
| 619 | clearTimeout(bufferReplayTimerRef.current); | ||
| 620 | bufferReplayTimerRef.current = null; | ||
| 621 | } | ||
| 622 | }, []); | ||
| 623 | |||
| 624 | const scheduleBufferReplayReady = useCallback(() => { | ||
| 625 | clearBufferReplayTimer(); | ||
| 626 | const replayKey = bufferReplayKey; | ||
| 627 | terminalDebugLog("replay:schedule-ready", { | ||
| 628 | replayKey, | ||
| 629 | delayMs: TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, | ||
| 630 | }); | ||
| 631 | bufferReplayTimerRef.current = setTimeout(() => { | ||
| 632 | bufferReplayTimerRef.current = null; | ||
| 633 | setReadyBufferReplayKey(replayKey); | ||
| 634 | terminalDebugLog("replay:ready", { replayKey }); | ||
| 635 | }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS); | ||
| 636 | }, [bufferReplayKey, clearBufferReplayTimer]); | ||
| 637 | |||
| 638 | useEffect(() => { | ||
| 639 | if (lastBufferReplayKeyRef.current === bufferReplayKey) { | ||
| 640 | return; | ||
| 641 | } | ||
| 642 | |||
| 643 | lastBufferReplayKeyRef.current = bufferReplayKey; | ||
| 644 | clearBufferReplayTimer(); | ||
| 645 | setReadyBufferReplayKey(null); | ||
| 646 | }, [bufferReplayKey, clearBufferReplayTimer]); | ||
| 647 | |||
| 648 | useEffect(() => clearBufferReplayTimer, [clearBufferReplayTimer]); | ||
| 649 | |||
| 650 | useEffect(() => { | ||
| 651 | if (!routeEnvironmentId || !routeThreadId) { | ||
| 652 | setLastGridSize({ | ||
| 653 | cols: DEFAULT_TERMINAL_COLS, | ||
| 654 | rows: DEFAULT_TERMINAL_ROWS, | ||
| 655 | }); | ||
| 656 | return; | ||
| 657 | } | ||
| 658 | |||
| 659 | setLastGridSize( | ||
| 660 | getCachedTerminalGridSize({ | ||
| 661 | environmentId: routeEnvironmentId, | ||
| 662 | threadId: routeThreadId, | ||
| 663 | terminalId, | ||
| 664 | }) ?? { | ||
| 665 | cols: DEFAULT_TERMINAL_COLS, | ||
| 666 | rows: DEFAULT_TERMINAL_ROWS, | ||
| 667 | }, | ||
| 668 | ); | ||
| 669 | setHasMeasuredSurface(true); | ||
| 670 | }, [routeEnvironmentId, routeThreadId, terminalId]); | ||
| 671 | |||
| 672 | useEffect(() => { | ||
| 673 | let cancelled = false; | ||
| 674 | |||
| 675 | void loadPreferences() | ||
| 676 | .then((preferences) => { | ||
| 677 | if (cancelled) { | ||
| 678 | return; | ||
| 679 | } | ||
| 680 | |||
| 681 | setFontSize(cacheTerminalFontSize(preferences.terminalFontSize)); | ||
| 682 | setHasResolvedFontPreference(true); | ||
| 683 | }) | ||
| 684 | .catch(() => { | ||
| 685 | if (cancelled) { | ||
| 686 | return; | ||
| 687 | } | ||
| 688 | |||
| 689 | setHasResolvedFontPreference(true); | ||
| 690 | }); | ||
| 691 | |||
| 692 | return () => { | ||
| 693 | cancelled = true; | ||
| 694 | }; | ||
| 695 | }, []); | ||
| 696 | |||
| 697 | useEffect(() => { | ||
| 698 | if (!hasResolvedFontPreference) { | ||
| 699 | return; | ||
| 700 | } | ||
| 701 | |||
| 702 | cacheTerminalFontSize(fontSize); | ||
| 703 | void savePreferencesPatch({ | ||
| 704 | terminalFontSize: normalizeTerminalFontSize(fontSize), | ||
| 705 | }); | ||
| 706 | }, [fontSize, hasResolvedFontPreference]); | ||
| 707 | |||
| 708 | // Subscribes `terminal.attach` once per route+terminal until thread/env/attach args change. | ||
| 709 | // Use refs for `attachTerminal` / `selectedThread` / `runningSession`: their identities change when | ||
| 710 | // unrelated store updates (e.g. terminal buffer) re-render the parent, which was firing cleanup | ||
| 711 | // → detach immediately after the first snapshot. | ||
| 712 | useEffect(() => { | ||
| 713 | if (!hasResolvedFontPreference || !hasMeasuredSurface) { | ||
| 714 | return; | ||
| 715 | } | ||
| 716 | |||
| 717 | const thread = selectedThreadRef.current; | ||
| 718 | const project = selectedThreadProjectBootstrapRef.current; | ||
| 719 | const running = runningSessionRef.current; | ||
| 720 | const termSnap = terminalBootstrapRef.current; | ||
| 721 | |||
| 722 | const bootstrapAction = resolveTerminalRouteBootstrap({ | ||
| 723 | hasThread: thread !== null, | ||
| 724 | hasWorkspaceRoot: Boolean(project?.workspaceRoot), | ||
| 725 | hasOpened: hasOpenedRef.current, | ||
| 726 | requestedTerminalId, | ||
| 727 | currentTerminalId: terminalId, | ||
| 728 | runningTerminalId: running?.target.terminalId ?? null, | ||
| 729 | currentTerminalStatus: termSnap.status, | ||
| 730 | // Metadata summary (cwd/status) is not scrollback. Only `terminal.attach` fills `buffer`; | ||
| 731 | // treating summary as "hydrated" skipped attach while status was running → empty surface. | ||
| 732 | hasCurrentTerminalHydration: termSnap.bufferLen > 0, | ||
| 733 | }); | ||
| 734 | if (bootstrapAction.kind !== "idle") { | ||
| 735 | terminalDebugLog("bootstrap:action", { | ||
| 736 | kind: bootstrapAction.kind, | ||
| 737 | hasOpenedBefore: hasOpenedRef.current, | ||
| 738 | hasHydration: termSnap.bufferLen > 0, | ||
| 739 | terminalStatus: termSnap.status, | ||
| 740 | bufLen: termSnap.bufferLen, | ||
| 741 | }); | ||
| 742 | } | ||
| 743 | if (bootstrapAction.kind === "idle" || !thread) { | ||
| 744 | return; | ||
| 745 | } | ||
| 746 | |||
| 747 | if (bootstrapAction.kind === "redirect") { | ||
| 748 | router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId)); | ||
| 749 | return; | ||
| 750 | } | ||
| 751 | |||
| 752 | hasOpenedRef.current = true; | ||
| 753 | try { | ||
| 754 | const detach = attachTerminalRef.current(); | ||
| 755 | terminalDebugLog("bootstrap:subscribe", { hasDetach: Boolean(detach) }); | ||
| 756 | if (!detach) { | ||
| 757 | hasOpenedRef.current = false; | ||
| 758 | return; | ||
| 759 | } | ||
| 760 | return () => { | ||
| 761 | detach(); | ||
| 762 | hasOpenedRef.current = false; | ||
| 763 | terminalDebugLog("bootstrap:unsubscribe"); | ||
| 764 | }; | ||
| 765 | } catch (error) { | ||
| 766 | hasOpenedRef.current = false; | ||
| 767 | terminalDebugLog("bootstrap:attach-threw", { | ||
| 768 | message: error instanceof Error ? error.message : String(error), | ||
| 769 | }); | ||
| 770 | return; | ||
| 771 | } | ||
| 772 | }, [ | ||
| 773 | hasMeasuredSurface, | ||
| 774 | hasResolvedFontPreference, | ||
| 775 | requestedTerminalId, | ||
| 776 | router, | ||
| 777 | selectedThread?.environmentId, | ||
| 778 | selectedThread?.id, | ||
| 779 | selectedThreadProject?.workspaceRoot, | ||
| 780 | terminalId, | ||
| 781 | ]); | ||
| 782 | |||
| 783 | const writeInput = useCallback( | ||
| 784 | (data: string) => { | ||
| 785 | if (!selectedThread || !isRunning) { | ||
| 786 | return; | ||
| 787 | } | ||
| 788 | |||
| 789 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 790 | if (!client) { | ||
| 791 | return; | ||
| 792 | } | ||
| 793 | |||
| 794 | void client.terminal.write({ | ||
| 795 | threadId: selectedThread.id, | ||
| 796 | terminalId, | ||
| 797 | data, | ||
| 798 | }); | ||
| 799 | }, | ||
| 800 | [isRunning, selectedThread, terminalId], | ||
| 801 | ); | ||
| 802 | |||
| 803 | const handleInput = useCallback( | ||
| 804 | (data: string) => { | ||
| 805 | if (data.length === 0) { | ||
| 806 | return; | ||
| 807 | } | ||
| 808 | |||
| 809 | if (pendingModifier === "ctrl") { | ||
| 810 | setPendingModifierState({ terminalId, value: null }); | ||
| 811 | writeInput(applyCtrlModifier(data)); | ||
| 812 | } else if (pendingModifier === "meta") { | ||
| 813 | setPendingModifierState({ terminalId, value: null }); | ||
| 814 | writeInput(`\u001b${data}`); | ||
| 815 | } else { | ||
| 816 | writeInput(data); | ||
| 817 | } | ||
| 818 | }, | ||
| 819 | [pendingModifier, terminalId, writeInput], | ||
| 820 | ); | ||
| 821 | |||
| 822 | const handleResize = useCallback( | ||
| 823 | (size: { readonly cols: number; readonly rows: number }) => { | ||
| 824 | terminalDebugLog("native:onResize", { | ||
| 825 | cols: size.cols, | ||
| 826 | rows: size.rows, | ||
| 827 | terminalKey, | ||
| 828 | }); | ||
| 829 | setHasMeasuredSurface(true); | ||
| 830 | if (readyBufferReplayKey !== bufferReplayKey) { | ||
| 831 | scheduleBufferReplayReady(); | ||
| 832 | } | ||
| 833 | if (routeEnvironmentId && routeThreadId) { | ||
| 834 | cacheTerminalGridSize( | ||
| 835 | { | ||
| 836 | environmentId: routeEnvironmentId, | ||
| 837 | threadId: routeThreadId, | ||
| 838 | terminalId, | ||
| 839 | }, | ||
| 840 | size, | ||
| 841 | ); | ||
| 842 | } | ||
| 843 | if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) { | ||
| 844 | return; | ||
| 845 | } | ||
| 846 | |||
| 847 | setLastGridSize(size); | ||
| 848 | if (!selectedThread || !isRunning) { | ||
| 849 | return; | ||
| 850 | } | ||
| 851 | |||
| 852 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 853 | if (!client) { | ||
| 854 | return; | ||
| 855 | } | ||
| 856 | |||
| 857 | void client.terminal.resize({ | ||
| 858 | threadId: selectedThread.id, | ||
| 859 | terminalId, | ||
| 860 | cols: size.cols, | ||
| 861 | rows: size.rows, | ||
| 862 | }); | ||
| 863 | }, | ||
| 864 | [ | ||
| 865 | isRunning, | ||
| 866 | lastGridSize.cols, | ||
| 867 | lastGridSize.rows, | ||
| 868 | bufferReplayKey, | ||
| 869 | readyBufferReplayKey, | ||
| 870 | routeEnvironmentId, | ||
| 871 | routeThreadId, | ||
| 872 | scheduleBufferReplayReady, | ||
| 873 | selectedThread, | ||
| 874 | terminalId, | ||
| 875 | terminalKey, | ||
| 876 | ], | ||
| 877 | ); | ||
| 878 | |||
| 879 | const handleSelectTerminal = useCallback( | ||
| 880 | (nextTerminalId: string) => { | ||
| 881 | if (!selectedThread || nextTerminalId === terminalId) { | ||
| 882 | return; | ||
| 883 | } | ||
| 884 | |||
| 885 | router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); | ||
| 886 | }, | ||
| 887 | [router, selectedThread, terminalId], | ||
| 888 | ); | ||
| 889 | |||
| 890 | const handleOpenNewTerminal = useCallback(() => { | ||
| 891 | if (!selectedThread) { | ||
| 892 | return; | ||
| 893 | } | ||
| 894 | |||
| 895 | router.replace( | ||
| 896 | buildThreadTerminalNavigation( | ||
| 897 | selectedThread, | ||
| 898 | nextOpenTerminalId({ | ||
| 899 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | ||
| 900 | activeRouteTerminalId: terminalId, | ||
| 901 | }), | ||
| 902 | ), | ||
| 903 | ); | ||
| 904 | }, [router, selectedThread, terminalId, terminalMenuSessions]); | ||
| 905 | |||
| 906 | const adjustFontSize = useCallback((delta: number) => { | ||
| 907 | setTimeout(() => { | ||
| 908 | setFontSize((current) => cacheTerminalFontSize(current + delta)); | ||
| 909 | }, 0); | ||
| 910 | }, []); | ||
| 911 | |||
| 912 | const handleDecreaseFontSize = useCallback(() => { | ||
| 913 | adjustFontSize(-TERMINAL_FONT_SIZE_STEP); | ||
| 914 | }, [adjustFontSize]); | ||
| 915 | |||
| 916 | const handleIncreaseFontSize = useCallback(() => { | ||
| 917 | adjustFontSize(TERMINAL_FONT_SIZE_STEP); | ||
| 918 | }, [adjustFontSize]); | ||
| 919 | |||
| 920 | const handleClearTerminal = useCallback(() => { | ||
| 921 | if (!selectedThread) { | ||
| 922 | return; | ||
| 923 | } | ||
| 924 | |||
| 925 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 926 | if (!client) { | ||
| 927 | return; | ||
| 928 | } | ||
| 929 | |||
| 930 | setPendingModifierState({ terminalId, value: null }); | ||
| 931 | void client.terminal.clear({ | ||
| 932 | threadId: selectedThread.id, | ||
| 933 | terminalId, | ||
| 934 | }); | ||
| 935 | }, [selectedThread, terminalId]); | ||
| 936 | |||
| 937 | const handleToolbarActionPress = useCallback( | ||
| 938 | (action: TerminalToolbarAction) => { | ||
| 939 | if (action.kind === "modifier") { | ||
| 940 | setPendingModifierState((current) => ({ | ||
| 941 | terminalId, | ||
| 942 | value: | ||
| 943 | (current.terminalId === terminalId ? current.value : null) === action.modifier | ||
| 944 | ? null | ||
| 945 | : action.modifier, | ||
| 946 | })); | ||
| 947 | return; | ||
| 948 | } | ||
| 949 | |||
| 950 | if (action.kind === "clear") { | ||
| 951 | handleClearTerminal(); | ||
| 952 | return; | ||
| 953 | } | ||
| 954 | |||
| 955 | setPendingModifierState({ terminalId, value: null }); | ||
| 956 | if (pendingModifier === "ctrl") { | ||
| 957 | writeInput(applyCtrlModifier(action.data)); | ||
| 958 | } else if (pendingModifier === "meta") { | ||
| 959 | writeInput(`\u001b${action.data}`); | ||
| 960 | } else { | ||
| 961 | writeInput(action.data); | ||
| 962 | } | ||
| 963 | }, | ||
| 964 | [handleClearTerminal, pendingModifier, terminalId, writeInput], | ||
| 965 | ); | ||
| 966 | |||
| 967 | const updateToolbarScrollEdges = useCallback( | ||
| 968 | (patch: Partial<(typeof toolbarScrollGeometryRef)["current"]>) => { | ||
| 969 | const nextGeometry = { ...toolbarScrollGeometryRef.current, ...patch }; | ||
| 970 | toolbarScrollGeometryRef.current = nextGeometry; | ||
| 971 | const nextEdges = { | ||
| 972 | showLeftFade: nextGeometry.offsetX > 1, | ||
| 973 | showRightFade: | ||
| 974 | nextGeometry.offsetX + nextGeometry.viewportWidth < nextGeometry.contentWidth - 1, | ||
| 975 | }; | ||
| 976 | |||
| 977 | setToolbarScrollEdges((current) => | ||
| 978 | current.showLeftFade === nextEdges.showLeftFade && | ||
| 979 | current.showRightFade === nextEdges.showRightFade | ||
| 980 | ? current | ||
| 981 | : nextEdges, | ||
| 982 | ); | ||
| 983 | }, | ||
| 984 | [], | ||
| 985 | ); | ||
| 986 | |||
| 987 | const handleToolbarLayout = useCallback( | ||
| 988 | (event: LayoutChangeEvent) => { | ||
| 989 | updateToolbarScrollEdges({ viewportWidth: event.nativeEvent.layout.width }); | ||
| 990 | }, | ||
| 991 | [updateToolbarScrollEdges], | ||
| 992 | ); | ||
| 993 | |||
| 994 | const handleToolbarContentSizeChange = useCallback( | ||
| 995 | (contentWidth: number) => { | ||
| 996 | updateToolbarScrollEdges({ contentWidth }); | ||
| 997 | }, | ||
| 998 | [updateToolbarScrollEdges], | ||
| 999 | ); | ||
| 1000 | |||
| 1001 | const handleToolbarScroll = useCallback( | ||
| 1002 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 1003 | updateToolbarScrollEdges({ offsetX: event.nativeEvent.contentOffset.x }); | ||
| 1004 | }, | ||
| 1005 | [updateToolbarScrollEdges], | ||
| 1006 | ); | ||
| 1007 | |||
| 1008 | const handleDismissKeyboard = useCallback(() => { | ||
| 1009 | setIsAccessoryDismissed(true); | ||
| 1010 | void KeyboardController.dismiss(); | ||
| 1011 | }, []); | ||
| 1012 | |||
| 1013 | const handleShowKeyboard = useCallback(() => { | ||
| 1014 | setKeyboardFocusRequest((current) => current + 1); | ||
| 1015 | }, []); | ||
| 1016 | |||
| 1017 | if (!selectedThread) { | ||
| 1018 | if (isLoadingSavedConnection) { | ||
| 1019 | return <LoadingScreen message="Opening terminal…" />; | ||
| 1020 | } | ||
| 1021 | |||
| 1022 | return ( | ||
| 1023 | <View className="flex-1 bg-screen"> | ||
| 1024 | <EmptyState | ||
| 1025 | title="Thread unavailable" | ||
| 1026 | detail="This terminal route needs an active thread and workspace." | ||
| 1027 | /> | ||
| 1028 | </View> | ||
| 1029 | ); | ||
| 1030 | } | ||
| 1031 | |||
| 1032 | if (!selectedThreadProject?.workspaceRoot) { | ||
| 1033 | return ( | ||
| 1034 | <View className="flex-1 bg-screen"> | ||
| 1035 | <EmptyState | ||
| 1036 | title="Terminal unavailable" | ||
| 1037 | detail="This thread does not have a workspace root yet, so there is nowhere to open a shell." | ||
| 1038 | /> | ||
| 1039 | </View> | ||
| 1040 | ); | ||
| 1041 | } | ||
| 1042 | |||
| 1043 | return ( | ||
| 1044 | <> | ||
| 1045 | <Stack.Screen | ||
| 1046 | options={{ | ||
| 1047 | headerShown: true, | ||
| 1048 | headerBackButtonDisplayMode: "minimal", | ||
| 1049 | headerBackTitle: "", | ||
| 1050 | headerShadowVisible: false, | ||
| 1051 | headerStyle: { backgroundColor: terminalTheme.background }, | ||
| 1052 | headerTintColor: terminalTheme.foreground, | ||
| 1053 | headerTitleAlign: "center", | ||
| 1054 | title: "", | ||
| 1055 | headerTitle: () => ( | ||
| 1056 | <View | ||
| 1057 | style={{ | ||
| 1058 | alignItems: "center", | ||
| 1059 | gap: 1, | ||
| 1060 | maxWidth: 240, | ||
| 1061 | }} | ||
| 1062 | > | ||
| 1063 | <RNText | ||
| 1064 | numberOfLines={1} | ||
| 1065 | style={{ | ||
| 1066 | color: terminalTheme.foreground, | ||
| 1067 | fontFamily: "DMSans_700Bold", | ||
| 1068 | fontSize: 13, | ||
| 1069 | lineHeight: 16, | ||
| 1070 | }} | ||
| 1071 | > | ||
| 1072 | {headerTitle.topLine} | ||
| 1073 | </RNText> | ||
| 1074 | <RNText | ||
| 1075 | ellipsizeMode="middle" | ||
| 1076 | numberOfLines={1} | ||
| 1077 | style={{ | ||
| 1078 | color: terminalTheme.mutedForeground, | ||
| 1079 | fontFamily: "Menlo", | ||
| 1080 | fontSize: 11, | ||
| 1081 | lineHeight: 14, | ||
| 1082 | }} | ||
| 1083 | > | ||
| 1084 | {headerTitle.bottomLine} | ||
| 1085 | </RNText> | ||
| 1086 | </View> | ||
| 1087 | ), | ||
| 1088 | }} | ||
| 1089 | /> | ||
| 1090 | |||
| 1091 | <Stack.Toolbar placement="right"> | ||
| 1092 | <Stack.Toolbar.Menu | ||
| 1093 | icon="circle.fill" | ||
| 1094 | style={{ | ||
| 1095 | color: terminalStatusTone.textColor, | ||
| 1096 | fontFamily: "DMSans_700Bold", | ||
| 1097 | fontSize: 12, | ||
| 1098 | fontWeight: "700", | ||
| 1099 | }} | ||
| 1100 | tintColor={terminalStatusTone.tintColor} | ||
| 1101 | title="Terminal options" | ||
| 1102 | > | ||
| 1103 | <Stack.Toolbar.Label> | ||
| 1104 | {getTerminalStatusLabel({ | ||
| 1105 | status: terminal.status, | ||
| 1106 | hasRunningSubprocess: terminal.hasRunningSubprocess, | ||
| 1107 | })} | ||
| 1108 | </Stack.Toolbar.Label> | ||
| 1109 | <Stack.Toolbar.Menu icon="textformat.size" inline title="Text size"> | ||
| 1110 | <Stack.Toolbar.Label>Text size</Stack.Toolbar.Label> | ||
| 1111 | <Stack.Toolbar.MenuAction | ||
| 1112 | disabled={fontSize <= MIN_TERMINAL_FONT_SIZE} | ||
| 1113 | discoverabilityLabel="Decrease terminal text size" | ||
| 1114 | onPress={handleDecreaseFontSize} | ||
| 1115 | > | ||
| 1116 | <Stack.Toolbar.Label>{`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label> | ||
| 1117 | </Stack.Toolbar.MenuAction> | ||
| 1118 | <Stack.Toolbar.MenuAction | ||
| 1119 | disabled={fontSize >= MAX_TERMINAL_FONT_SIZE} | ||
| 1120 | discoverabilityLabel="Increase terminal text size" | ||
| 1121 | onPress={handleIncreaseFontSize} | ||
| 1122 | > | ||
| 1123 | <Stack.Toolbar.Label>{`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}</Stack.Toolbar.Label> | ||
| 1124 | </Stack.Toolbar.MenuAction> | ||
| 1125 | </Stack.Toolbar.Menu> | ||
| 1126 | {terminalMenuSessions.map((session) => ( | ||
| 1127 | <Stack.Toolbar.MenuAction | ||
| 1128 | key={session.terminalId} | ||
| 1129 | icon={session.terminalId === terminalId ? "checkmark" : "terminal"} | ||
| 1130 | onPress={() => handleSelectTerminal(session.terminalId)} | ||
| 1131 | subtitle={[getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] | ||
| 1132 | .filter(Boolean) | ||
| 1133 | .join(" · ")} | ||
| 1134 | > | ||
| 1135 | <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> | ||
| 1136 | </Stack.Toolbar.MenuAction> | ||
| 1137 | ))} | ||
| 1138 | <Stack.Toolbar.MenuAction | ||
| 1139 | icon="plus" | ||
| 1140 | onPress={handleOpenNewTerminal} | ||
| 1141 | subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`} | ||
| 1142 | > | ||
| 1143 | <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label> | ||
| 1144 | </Stack.Toolbar.MenuAction> | ||
| 1145 | </Stack.Toolbar.Menu> | ||
| 1146 | </Stack.Toolbar> | ||
| 1147 | |||
| 1148 | <View style={{ flex: 1, backgroundColor: terminalTheme.background }}> | ||
| 1149 | <View style={{ flex: 1, paddingBottom: terminalBottomInset }}> | ||
| 1150 | <TerminalSurface | ||
| 1151 | buffer={terminalSurfaceBuffer} | ||
| 1152 | fontSize={fontSize} | ||
| 1153 | isRunning={isRunning} | ||
| 1154 | keyboardFocusRequest={keyboardFocusRequest} | ||
| 1155 | onInput={handleInput} | ||
| 1156 | onResize={handleResize} | ||
| 1157 | style={{ flex: 1 }} | ||
| 1158 | terminalKey={terminalKey} | ||
| 1159 | /> | ||
| 1160 | </View> | ||
| 1161 | |||
| 1162 | {isAccessoryVisible ? ( | ||
| 1163 | <KeyboardStickyView | ||
| 1164 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | ||
| 1165 | offset={{ closed: 0, opened: 0 }} | ||
| 1166 | > | ||
| 1167 | <View | ||
| 1168 | style={{ | ||
| 1169 | backgroundColor: terminalTheme.background, | ||
| 1170 | borderTopColor: terminalTheme.border, | ||
| 1171 | borderTopWidth: 1, | ||
| 1172 | minHeight: TERMINAL_ACCESSORY_HEIGHT, | ||
| 1173 | paddingBottom: 4, | ||
| 1174 | paddingHorizontal: 8, | ||
| 1175 | paddingTop: 4, | ||
| 1176 | }} | ||
| 1177 | > | ||
| 1178 | <View style={{ alignItems: "center", flexDirection: "row", gap: 6 }}> | ||
| 1179 | <View style={{ flex: 1, position: "relative" }}> | ||
| 1180 | <ScrollView | ||
| 1181 | horizontal | ||
| 1182 | contentContainerStyle={{ alignItems: "center", gap: 6, paddingRight: 2 }} | ||
| 1183 | onContentSizeChange={handleToolbarContentSizeChange} | ||
| 1184 | onLayout={handleToolbarLayout} | ||
| 1185 | onScroll={handleToolbarScroll} | ||
| 1186 | scrollEventThrottle={16} | ||
| 1187 | showsHorizontalScrollIndicator={false} | ||
| 1188 | > | ||
| 1189 | {terminalToolbarActions.map((action) => { | ||
| 1190 | const active = | ||
| 1191 | action.kind === "modifier" && pendingModifier === action.modifier; | ||
| 1192 | |||
| 1193 | return ( | ||
| 1194 | <Pressable | ||
| 1195 | key={action.key} | ||
| 1196 | onPress={() => handleToolbarActionPress(action)} | ||
| 1197 | style={({ pressed }) => ({ | ||
| 1198 | alignItems: "center", | ||
| 1199 | backgroundColor: active | ||
| 1200 | ? withAlpha( | ||
| 1201 | terminalTheme.palette[10] ?? terminalTheme.foreground, | ||
| 1202 | "2e", | ||
| 1203 | ) | ||
| 1204 | : pressed | ||
| 1205 | ? withAlpha(terminalTheme.foreground, "1f") | ||
| 1206 | : withAlpha(terminalTheme.foreground, "12"), | ||
| 1207 | borderColor: active | ||
| 1208 | ? withAlpha( | ||
| 1209 | terminalTheme.palette[10] ?? terminalTheme.foreground, | ||
| 1210 | "52", | ||
| 1211 | ) | ||
| 1212 | : terminalTheme.border, | ||
| 1213 | borderRadius: 12, | ||
| 1214 | borderWidth: 1, | ||
| 1215 | justifyContent: "center", | ||
| 1216 | minWidth: action.label.length > 1 ? 46 : 38, | ||
| 1217 | paddingHorizontal: 11, | ||
| 1218 | paddingVertical: 8, | ||
| 1219 | })} | ||
| 1220 | > | ||
| 1221 | <RNText | ||
| 1222 | style={{ | ||
| 1223 | color: active | ||
| 1224 | ? (terminalTheme.palette[10] ?? terminalTheme.foreground) | ||
| 1225 | : terminalTheme.foreground, | ||
| 1226 | fontFamily: "DMSans_700Bold", | ||
| 1227 | fontSize: 12, | ||
| 1228 | fontWeight: "700", | ||
| 1229 | textTransform: | ||
| 1230 | action.kind === "modifier" || action.kind === "clear" | ||
| 1231 | ? "uppercase" | ||
| 1232 | : "none", | ||
| 1233 | }} | ||
| 1234 | > | ||
| 1235 | {action.label} | ||
| 1236 | </RNText> | ||
| 1237 | </Pressable> | ||
| 1238 | ); | ||
| 1239 | })} | ||
| 1240 | </ScrollView> | ||
| 1241 | {toolbarScrollEdges.showLeftFade ? ( | ||
| 1242 | <View | ||
| 1243 | pointerEvents="none" | ||
| 1244 | style={{ | ||
| 1245 | bottom: 0, | ||
| 1246 | experimental_backgroundImage: `linear-gradient(to right, ${terminalTheme.background} 0%, ${withAlpha(terminalTheme.background, "00")} 100%)`, | ||
| 1247 | left: 0, | ||
| 1248 | position: "absolute", | ||
| 1249 | top: 0, | ||
| 1250 | width: TERMINAL_ACCESSORY_FADE_WIDTH, | ||
| 1251 | }} | ||
| 1252 | /> | ||
| 1253 | ) : null} | ||
| 1254 | {toolbarScrollEdges.showRightFade ? ( | ||
| 1255 | <View | ||
| 1256 | pointerEvents="none" | ||
| 1257 | style={{ | ||
| 1258 | bottom: 0, | ||
| 1259 | experimental_backgroundImage: `linear-gradient(to right, ${withAlpha(terminalTheme.background, "00")} 0%, ${terminalTheme.background} 100%)`, | ||
| 1260 | position: "absolute", | ||
| 1261 | right: 0, | ||
| 1262 | top: 0, | ||
| 1263 | width: TERMINAL_ACCESSORY_FADE_WIDTH, | ||
| 1264 | }} | ||
| 1265 | /> | ||
| 1266 | ) : null} | ||
| 1267 | </View> | ||
| 1268 | <Pressable | ||
| 1269 | accessibilityLabel="Dismiss keyboard" | ||
| 1270 | accessibilityRole="button" | ||
| 1271 | onPress={handleDismissKeyboard} | ||
| 1272 | style={({ pressed }) => ({ | ||
| 1273 | alignItems: "center", | ||
| 1274 | backgroundColor: pressed | ||
| 1275 | ? withAlpha(terminalTheme.foreground, "1f") | ||
| 1276 | : withAlpha(terminalTheme.foreground, "12"), | ||
| 1277 | borderColor: terminalTheme.border, | ||
| 1278 | borderRadius: 12, | ||
| 1279 | borderWidth: 1, | ||
| 1280 | justifyContent: "center", | ||
| 1281 | minWidth: 38, | ||
| 1282 | paddingHorizontal: 11, | ||
| 1283 | paddingVertical: 8, | ||
| 1284 | })} | ||
| 1285 | > | ||
| 1286 | <SymbolView | ||
| 1287 | name={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }} | ||
| 1288 | size={14} | ||
| 1289 | tintColor={terminalTheme.foreground} | ||
| 1290 | type="monochrome" | ||
| 1291 | /> | ||
| 1292 | </Pressable> | ||
| 1293 | </View> | ||
| 1294 | </View> | ||
| 1295 | </KeyboardStickyView> | ||
| 1296 | ) : !keyboardState.isVisible ? ( | ||
| 1297 | <Pressable | ||
| 1298 | accessibilityLabel="Show keyboard" | ||
| 1299 | accessibilityRole="button" | ||
| 1300 | onPress={handleShowKeyboard} | ||
| 1301 | style={({ pressed }) => ({ | ||
| 1302 | bottom: 16, | ||
| 1303 | borderRadius: 28, | ||
| 1304 | opacity: pressed ? 0.72 : 1, | ||
| 1305 | position: "absolute", | ||
| 1306 | right: 16, | ||
| 1307 | })} | ||
| 1308 | > | ||
| 1309 | <GlassSurface | ||
| 1310 | chrome="none" | ||
| 1311 | glassEffectStyle="regular" | ||
| 1312 | tintColor="transparent" | ||
| 1313 | style={{ | ||
| 1314 | alignItems: "center", | ||
| 1315 | borderRadius: 24, | ||
| 1316 | height: 48, | ||
| 1317 | justifyContent: "center", | ||
| 1318 | width: 48, | ||
| 1319 | }} | ||
| 1320 | pointerEvents="none" | ||
| 1321 | > | ||
| 1322 | <SymbolView | ||
| 1323 | name={{ ios: "keyboard", android: "keyboard" }} | ||
| 1324 | size={20} | ||
| 1325 | tintColor={terminalTheme.foreground} | ||
| 1326 | type="monochrome" | ||
| 1327 | /> | ||
| 1328 | </GlassSurface> | ||
| 1329 | </Pressable> | ||
| 1330 | ) : null} | ||
| 1331 | </View> | ||
| 1332 | </> | ||
| 1333 | ); | ||
| 1334 | } | ||
apps/mobile/src/features/terminal/nativeTerminalModule.test.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | const expoMocks = vi.hoisted(() => ({ | ||
| 4 | requireNativeView: vi.fn(), | ||
| 5 | })); | ||
| 6 | const nativeView = () => null; | ||
| 7 | const originalExpo = globalThis.expo; | ||
| 8 | |||
| 9 | function setExpoViewConfigAvailable() { | ||
| 10 | globalThis.expo = { | ||
| 11 | getViewConfig: vi.fn().mockReturnValue({ validAttributes: {}, directEventTypes: {} }), | ||
| 12 | } as unknown as typeof globalThis.expo; | ||
| 13 | } | ||
| 14 | |||
| 15 | vi.mock("expo", () => ({ | ||
| 16 | requireNativeView: expoMocks.requireNativeView, | ||
| 17 | })); | ||
| 18 | |||
| 19 | describe("resolveNativeTerminalSurfaceView", () => { | ||
| 20 | beforeEach(() => { | ||
| 21 | vi.clearAllMocks(); | ||
| 22 | vi.resetModules(); | ||
| 23 | globalThis.expo = undefined as unknown as typeof globalThis.expo; | ||
| 24 | }); | ||
| 25 | |||
| 26 | afterEach(() => { | ||
| 27 | globalThis.expo = originalExpo; | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("returns null when the native terminal view config is unavailable", async () => { | ||
| 31 | const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule"); | ||
| 32 | expect(resolveNativeTerminalSurfaceView()).toBeNull(); | ||
| 33 | expect(expoMocks.requireNativeView).not.toHaveBeenCalled(); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("returns the native terminal view when the view config is installed", async () => { | ||
| 37 | setExpoViewConfigAvailable(); | ||
| 38 | expoMocks.requireNativeView.mockReturnValue(nativeView); | ||
| 39 | const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule"); | ||
| 40 | expect(resolveNativeTerminalSurfaceView()).toBe(nativeView); | ||
| 41 | expect(expoMocks.requireNativeView).toHaveBeenCalledWith("T3TerminalSurface"); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("returns null when the view manager cannot be required", async () => { | ||
| 45 | setExpoViewConfigAvailable(); | ||
| 46 | expoMocks.requireNativeView.mockImplementation(() => { | ||
| 47 | throw new Error("boom"); | ||
| 48 | }); | ||
| 49 | const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule"); | ||
| 50 | expect(resolveNativeTerminalSurfaceView()).toBeNull(); | ||
| 51 | }); | ||
| 52 | }); | ||
apps/mobile/src/features/terminal/nativeTerminalModule.ts created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | import type { ComponentType } from "react"; | ||
| 2 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; | ||
| 3 | import { requireNativeView } from "expo"; | ||
| 4 | |||
| 5 | const NATIVE_TERMINAL_MODULE_NAME = "T3TerminalSurface"; | ||
| 6 | |||
| 7 | interface ExpoGlobalWithViewConfig { | ||
| 8 | readonly expo?: { | ||
| 9 | getViewConfig?: (moduleName: string, viewName?: string) => unknown; | ||
| 10 | }; | ||
| 11 | } | ||
| 12 | |||
| 13 | interface TerminalInputEvent { | ||
| 14 | readonly data: string; | ||
| 15 | } | ||
| 16 | |||
| 17 | interface TerminalResizeEvent { | ||
| 18 | readonly cols: number; | ||
| 19 | readonly rows: number; | ||
| 20 | } | ||
| 21 | |||
| 22 | export interface NativeTerminalSurfaceProps extends ViewProps { | ||
| 23 | readonly appearanceScheme?: "light" | "dark"; | ||
| 24 | readonly themeConfig?: string; | ||
| 25 | readonly backgroundColor?: string; | ||
| 26 | readonly foregroundColor?: string; | ||
| 27 | readonly mutedForegroundColor?: string; | ||
| 28 | readonly terminalKey: string; | ||
| 29 | readonly initialBuffer: string; | ||
| 30 | readonly fontSize: number; | ||
| 31 | readonly onInput?: (event: NativeSyntheticEvent<TerminalInputEvent>) => void; | ||
| 32 | readonly onResize?: (event: NativeSyntheticEvent<TerminalResizeEvent>) => void; | ||
| 33 | } | ||
| 34 | |||
| 35 | let cachedNativeTerminalSurfaceView: ComponentType<NativeTerminalSurfaceProps> | undefined; | ||
| 36 | |||
| 37 | function getExpoViewConfig(moduleName: string) { | ||
| 38 | return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( | ||
| 39 | moduleName, | ||
| 40 | ); | ||
| 41 | } | ||
| 42 | |||
| 43 | export function resolveNativeTerminalSurfaceView(): ComponentType<NativeTerminalSurfaceProps> | null { | ||
| 44 | if (cachedNativeTerminalSurfaceView) { | ||
| 45 | return cachedNativeTerminalSurfaceView; | ||
| 46 | } | ||
| 47 | |||
| 48 | if (getExpoViewConfig(NATIVE_TERMINAL_MODULE_NAME) == null) { | ||
| 49 | return null; | ||
| 50 | } | ||
| 51 | |||
| 52 | try { | ||
| 53 | cachedNativeTerminalSurfaceView = requireNativeView<NativeTerminalSurfaceProps>( | ||
| 54 | NATIVE_TERMINAL_MODULE_NAME, | ||
| 55 | ); | ||
| 56 | } catch { | ||
| 57 | return null; | ||
| 58 | } | ||
| 59 | |||
| 60 | return cachedNativeTerminalSurfaceView ?? null; | ||
| 61 | } | ||
| 62 | |||
| 63 | export function hasNativeTerminalSurface() { | ||
| 64 | return resolveNativeTerminalSurfaceView() !== null; | ||
| 65 | } | ||
apps/mobile/src/features/terminal/terminalBufferReplay.test.ts created+43| ... | @@ -0,0 +1,43 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { getTerminalBufferReplayKey, getTerminalSurfaceReplayBuffer } from "./terminalBufferReplay"; | ||
| 4 | |||
| 5 | describe("terminalBufferReplay", () => { | ||
| 6 | it("keys replay readiness by terminal identity and font metrics", () => { | ||
| 7 | expect( | ||
| 8 | getTerminalBufferReplayKey({ | ||
| 9 | terminalKey: "env-1:thread-1:default", | ||
| 10 | fontSize: 10, | ||
| 11 | }), | ||
| 12 | ).toBe("env-1:thread-1:default:10"); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("shows terminal history while replay key is unset (initial mount / after key change)", () => { | ||
| 16 | const replayKey = getTerminalBufferReplayKey({ | ||
| 17 | terminalKey: "env-1:thread-1:default", | ||
| 18 | fontSize: 10, | ||
| 19 | }); | ||
| 20 | |||
| 21 | expect( | ||
| 22 | getTerminalSurfaceReplayBuffer({ | ||
| 23 | buffer: "fastfetch output", | ||
| 24 | replayKey, | ||
| 25 | readyReplayKey: null, | ||
| 26 | }), | ||
| 27 | ).toBe("fastfetch output"); | ||
| 28 | expect( | ||
| 29 | getTerminalSurfaceReplayBuffer({ | ||
| 30 | buffer: "fastfetch output", | ||
| 31 | replayKey, | ||
| 32 | readyReplayKey: "env-1:thread-1:default:11", | ||
| 33 | }), | ||
| 34 | ).toBe(""); | ||
| 35 | expect( | ||
| 36 | getTerminalSurfaceReplayBuffer({ | ||
| 37 | buffer: "fastfetch output", | ||
| 38 | replayKey, | ||
| 39 | readyReplayKey: replayKey, | ||
| 40 | }), | ||
| 41 | ).toBe("fastfetch output"); | ||
| 42 | }); | ||
| 43 | }); | ||
apps/mobile/src/features/terminal/terminalBufferReplay.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import { terminalDebugLog } from "./terminalDebugLog"; | ||
| 2 | |||
| 3 | export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180; | ||
| 4 | |||
| 5 | export function getTerminalBufferReplayKey(input: { | ||
| 6 | readonly terminalKey: string; | ||
| 7 | readonly fontSize: number; | ||
| 8 | }): string { | ||
| 9 | return `${input.terminalKey}:${input.fontSize}`; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function getTerminalSurfaceReplayBuffer(input: { | ||
| 13 | readonly buffer: string; | ||
| 14 | readonly replayKey: string; | ||
| 15 | readonly readyReplayKey: string | null; | ||
| 16 | }): string { | ||
| 17 | // Pass live buffer whenever ready key is unset or matches. Only return "" when ready key is | ||
| 18 | // stale vs current replay key (e.g. mid font-size transition). | ||
| 19 | if (input.readyReplayKey !== null && input.readyReplayKey !== input.replayKey) { | ||
| 20 | terminalDebugLog("replay:stale-key-hiding-buffer", { | ||
| 21 | replayKey: input.replayKey, | ||
| 22 | readyReplayKey: input.readyReplayKey, | ||
| 23 | bufferLen: input.buffer.length, | ||
| 24 | }); | ||
| 25 | return ""; | ||
| 26 | } | ||
| 27 | |||
| 28 | return input.buffer; | ||
| 29 | } | ||
apps/mobile/src/features/terminal/terminalDebugLog.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | /** | ||
| 2 | * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`. | ||
| 3 | * | ||
| 4 | * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS | ||
| 5 | * debugger / Metro console to trace release/TestFlight builds. | ||
| 6 | */ | ||
| 7 | export function isTerminalDebugEnabled(): boolean { | ||
| 8 | return ( | ||
| 9 | (typeof __DEV__ !== "undefined" && __DEV__) || | ||
| 10 | (typeof globalThis !== "undefined" && | ||
| 11 | (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true) | ||
| 12 | ); | ||
| 13 | } | ||
| 14 | |||
| 15 | export function terminalDebugLog(message: string, data?: Record<string, unknown>): void { | ||
| 16 | if (!isTerminalDebugEnabled()) { | ||
| 17 | return; | ||
| 18 | } | ||
| 19 | if (data !== undefined) { | ||
| 20 | console.log(`[t3-terminal] ${message}`, data); | ||
| 21 | } else { | ||
| 22 | console.log(`[t3-terminal] ${message}`); | ||
| 23 | } | ||
| 24 | } | ||
apps/mobile/src/features/terminal/terminalLaunchContext.test.ts created+130| ... | @@ -0,0 +1,130 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | peekPendingTerminalLaunch, | ||
| 6 | resolvePreferredThreadWorktreePath, | ||
| 7 | resolveTerminalOpenLocation, | ||
| 8 | stagePendingTerminalLaunch, | ||
| 9 | takePendingTerminalLaunch, | ||
| 10 | } from "./terminalLaunchContext"; | ||
| 11 | |||
| 12 | describe("resolvePreferredThreadWorktreePath", () => { | ||
| 13 | it("prefers thread detail worktree paths over thread shell paths", () => { | ||
| 14 | expect( | ||
| 15 | resolvePreferredThreadWorktreePath({ | ||
| 16 | threadShellWorktreePath: "/repo/root", | ||
| 17 | threadDetailWorktreePath: "/repo/worktrees/feature", | ||
| 18 | }), | ||
| 19 | ).toBe("/repo/worktrees/feature"); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("falls back to the thread shell worktree path when detail is unavailable", () => { | ||
| 23 | expect( | ||
| 24 | resolvePreferredThreadWorktreePath({ | ||
| 25 | threadShellWorktreePath: "/repo/worktrees/feature", | ||
| 26 | threadDetailWorktreePath: null, | ||
| 27 | }), | ||
| 28 | ).toBe("/repo/worktrees/feature"); | ||
| 29 | }); | ||
| 30 | }); | ||
| 31 | |||
| 32 | describe("resolveTerminalOpenLocation", () => { | ||
| 33 | it("uses the thread detail worktree path before the workspace root for a fresh mobile open", () => { | ||
| 34 | expect( | ||
| 35 | resolveTerminalOpenLocation({ | ||
| 36 | terminalLocation: null, | ||
| 37 | activeSessionLocation: null, | ||
| 38 | workspaceRoot: "/repo/root", | ||
| 39 | threadShellWorktreePath: null, | ||
| 40 | threadDetailWorktreePath: "/repo/worktrees/feature", | ||
| 41 | }), | ||
| 42 | ).toEqual({ | ||
| 43 | cwd: "/repo/worktrees/feature", | ||
| 44 | worktreePath: "/repo/worktrees/feature", | ||
| 45 | }); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("preserves the running terminal snapshot cwd when attaching to an existing session", () => { | ||
| 49 | expect( | ||
| 50 | resolveTerminalOpenLocation({ | ||
| 51 | terminalLocation: null, | ||
| 52 | activeSessionLocation: { | ||
| 53 | cwd: "/repo/worktrees/feature", | ||
| 54 | worktreePath: "/repo/worktrees/feature", | ||
| 55 | }, | ||
| 56 | workspaceRoot: "/repo/root", | ||
| 57 | threadShellWorktreePath: null, | ||
| 58 | threadDetailWorktreePath: "/repo/worktrees/other", | ||
| 59 | }), | ||
| 60 | ).toEqual({ | ||
| 61 | cwd: "/repo/worktrees/feature", | ||
| 62 | worktreePath: "/repo/worktrees/feature", | ||
| 63 | }); | ||
| 64 | }); | ||
| 65 | }); | ||
| 66 | |||
| 67 | describe("pending terminal launches", () => { | ||
| 68 | it("stages and consumes launch details for a specific terminal target", () => { | ||
| 69 | const target = { | ||
| 70 | environmentId: EnvironmentId.make("env-1"), | ||
| 71 | threadId: ThreadId.make("thread-1"), | ||
| 72 | terminalId: "term-2", | ||
| 73 | }; | ||
| 74 | |||
| 75 | stagePendingTerminalLaunch({ | ||
| 76 | target, | ||
| 77 | launch: { | ||
| 78 | cwd: "/repo/worktrees/feature", | ||
| 79 | worktreePath: "/repo/worktrees/feature", | ||
| 80 | env: { FOO: "bar" }, | ||
| 81 | initialInput: "pnpm dev\r", | ||
| 82 | }, | ||
| 83 | }); | ||
| 84 | |||
| 85 | expect(peekPendingTerminalLaunch(target)).toEqual({ | ||
| 86 | cwd: "/repo/worktrees/feature", | ||
| 87 | worktreePath: "/repo/worktrees/feature", | ||
| 88 | env: { FOO: "bar" }, | ||
| 89 | initialInput: "pnpm dev\r", | ||
| 90 | }); | ||
| 91 | expect(takePendingTerminalLaunch(target)).toEqual({ | ||
| 92 | cwd: "/repo/worktrees/feature", | ||
| 93 | worktreePath: "/repo/worktrees/feature", | ||
| 94 | env: { FOO: "bar" }, | ||
| 95 | initialInput: "pnpm dev\r", | ||
| 96 | }); | ||
| 97 | expect(peekPendingTerminalLaunch(target)).toBeNull(); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("keeps pending launches isolated per terminal target", () => { | ||
| 101 | const primaryTarget = { | ||
| 102 | environmentId: EnvironmentId.make("env-1"), | ||
| 103 | threadId: ThreadId.make("thread-1"), | ||
| 104 | terminalId: "term-2", | ||
| 105 | }; | ||
| 106 | const otherTarget = { | ||
| 107 | environmentId: EnvironmentId.make("env-1"), | ||
| 108 | threadId: ThreadId.make("thread-1"), | ||
| 109 | terminalId: "term-3", | ||
| 110 | }; | ||
| 111 | |||
| 112 | stagePendingTerminalLaunch({ | ||
| 113 | target: primaryTarget, | ||
| 114 | launch: { | ||
| 115 | cwd: "/repo/root", | ||
| 116 | worktreePath: null, | ||
| 117 | initialInput: "pnpm i\r", | ||
| 118 | }, | ||
| 119 | }); | ||
| 120 | |||
| 121 | expect(peekPendingTerminalLaunch(otherTarget)).toBeNull(); | ||
| 122 | expect(takePendingTerminalLaunch(otherTarget)).toBeNull(); | ||
| 123 | expect(takePendingTerminalLaunch(primaryTarget)).toEqual({ | ||
| 124 | cwd: "/repo/root", | ||
| 125 | worktreePath: null, | ||
| 126 | env: undefined, | ||
| 127 | initialInput: "pnpm i\r", | ||
| 128 | }); | ||
| 129 | }); | ||
| 130 | }); | ||
apps/mobile/src/features/terminal/terminalLaunchContext.ts created+90| ... | @@ -0,0 +1,90 @@ | ||
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | interface TerminalLocationLike { | ||
| 4 | readonly cwd: string; | ||
| 5 | readonly worktreePath: string | null; | ||
| 6 | } | ||
| 7 | |||
| 8 | interface PendingTerminalLaunchTarget { | ||
| 9 | readonly environmentId: EnvironmentId; | ||
| 10 | readonly threadId: ThreadId; | ||
| 11 | readonly terminalId: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | export interface PendingTerminalLaunch { | ||
| 15 | readonly cwd: string; | ||
| 16 | readonly worktreePath: string | null; | ||
| 17 | readonly env?: Record<string, string>; | ||
| 18 | readonly initialInput?: string; | ||
| 19 | } | ||
| 20 | |||
| 21 | const pendingTerminalLaunches = new Map<string, PendingTerminalLaunch>(); | ||
| 22 | |||
| 23 | function pendingTerminalLaunchKey(input: PendingTerminalLaunchTarget): string { | ||
| 24 | return `${input.environmentId}:${input.threadId}:${input.terminalId}`; | ||
| 25 | } | ||
| 26 | |||
| 27 | export function stagePendingTerminalLaunch(input: { | ||
| 28 | readonly target: PendingTerminalLaunchTarget; | ||
| 29 | readonly launch: PendingTerminalLaunch; | ||
| 30 | }) { | ||
| 31 | pendingTerminalLaunches.set(pendingTerminalLaunchKey(input.target), { | ||
| 32 | cwd: input.launch.cwd, | ||
| 33 | worktreePath: input.launch.worktreePath, | ||
| 34 | env: input.launch.env ? { ...input.launch.env } : undefined, | ||
| 35 | initialInput: input.launch.initialInput, | ||
| 36 | }); | ||
| 37 | } | ||
| 38 | |||
| 39 | export function peekPendingTerminalLaunch( | ||
| 40 | target: PendingTerminalLaunchTarget, | ||
| 41 | ): PendingTerminalLaunch | null { | ||
| 42 | return pendingTerminalLaunches.get(pendingTerminalLaunchKey(target)) ?? null; | ||
| 43 | } | ||
| 44 | |||
| 45 | export function takePendingTerminalLaunch( | ||
| 46 | target: PendingTerminalLaunchTarget, | ||
| 47 | ): PendingTerminalLaunch | null { | ||
| 48 | const key = pendingTerminalLaunchKey(target); | ||
| 49 | const launch = pendingTerminalLaunches.get(key) ?? null; | ||
| 50 | if (launch) { | ||
| 51 | pendingTerminalLaunches.delete(key); | ||
| 52 | } | ||
| 53 | |||
| 54 | return launch; | ||
| 55 | } | ||
| 56 | |||
| 57 | export function resolvePreferredThreadWorktreePath(input: { | ||
| 58 | readonly threadShellWorktreePath: string | null; | ||
| 59 | readonly threadDetailWorktreePath: string | null; | ||
| 60 | }): string | null { | ||
| 61 | return input.threadDetailWorktreePath ?? input.threadShellWorktreePath ?? null; | ||
| 62 | } | ||
| 63 | |||
| 64 | export function resolveTerminalOpenLocation(input: { | ||
| 65 | readonly terminalLocation: TerminalLocationLike | null; | ||
| 66 | readonly activeSessionLocation: TerminalLocationLike | null; | ||
| 67 | readonly workspaceRoot: string; | ||
| 68 | readonly threadShellWorktreePath: string | null; | ||
| 69 | readonly threadDetailWorktreePath: string | null; | ||
| 70 | }): { | ||
| 71 | readonly cwd: string; | ||
| 72 | readonly worktreePath: string | null; | ||
| 73 | } { | ||
| 74 | const preferredThreadWorktreePath = resolvePreferredThreadWorktreePath({ | ||
| 75 | threadShellWorktreePath: input.threadShellWorktreePath, | ||
| 76 | threadDetailWorktreePath: input.threadDetailWorktreePath, | ||
| 77 | }); | ||
| 78 | |||
| 79 | return { | ||
| 80 | cwd: | ||
| 81 | input.terminalLocation?.cwd ?? | ||
| 82 | input.activeSessionLocation?.cwd ?? | ||
| 83 | preferredThreadWorktreePath ?? | ||
| 84 | input.workspaceRoot, | ||
| 85 | worktreePath: | ||
| 86 | input.terminalLocation?.worktreePath ?? | ||
| 87 | input.activeSessionLocation?.worktreePath ?? | ||
| 88 | preferredThreadWorktreePath, | ||
| 89 | }; | ||
| 90 | } | ||
apps/mobile/src/features/terminal/terminalMenu.test.ts created+165| ... | @@ -0,0 +1,165 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; | ||
| 4 | import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | buildTerminalMenuSessions, | ||
| 10 | nextOpenTerminalId, | ||
| 11 | nextTerminalId, | ||
| 12 | resolveProjectScriptTerminalId, | ||
| 13 | } from "./terminalMenu"; | ||
| 14 | |||
| 15 | function makeKnownSession(input: { | ||
| 16 | readonly terminalId: string; | ||
| 17 | readonly status: KnownTerminalSession["state"]["status"]; | ||
| 18 | readonly cwd?: string | null; | ||
| 19 | readonly updatedAt?: string | null; | ||
| 20 | }): KnownTerminalSession { | ||
| 21 | return { | ||
| 22 | target: { | ||
| 23 | environmentId: EnvironmentId.make("env-1"), | ||
| 24 | threadId: ThreadId.make("thread-1"), | ||
| 25 | terminalId: input.terminalId, | ||
| 26 | }, | ||
| 27 | state: { | ||
| 28 | summary: input.cwd | ||
| 29 | ? { | ||
| 30 | threadId: "thread-1", | ||
| 31 | terminalId: input.terminalId, | ||
| 32 | cwd: input.cwd, | ||
| 33 | worktreePath: input.cwd, | ||
| 34 | status: input.status === "closed" ? "error" : input.status, | ||
| 35 | pid: input.status === "running" ? 123 : null, | ||
| 36 | exitCode: null, | ||
| 37 | exitSignal: null, | ||
| 38 | hasRunningSubprocess: false, | ||
| 39 | label: getTerminalLabel(input.terminalId), | ||
| 40 | updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", | ||
| 41 | } | ||
| 42 | : null, | ||
| 43 | buffer: "", | ||
| 44 | status: input.status, | ||
| 45 | error: null, | ||
| 46 | hasRunningSubprocess: false, | ||
| 47 | updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", | ||
| 48 | version: 1, | ||
| 49 | }, | ||
| 50 | }; | ||
| 51 | } | ||
| 52 | |||
| 53 | describe("buildTerminalMenuSessions", () => { | ||
| 54 | it("only lists server-known sessions that are running or starting (plus current)", () => { | ||
| 55 | expect( | ||
| 56 | buildTerminalMenuSessions({ | ||
| 57 | knownSessions: [ | ||
| 58 | makeKnownSession({ | ||
| 59 | terminalId: "term-3", | ||
| 60 | status: "running", | ||
| 61 | cwd: "/workspace/feature", | ||
| 62 | updatedAt: "2026-04-15T20:05:00.000Z", | ||
| 63 | }), | ||
| 64 | makeKnownSession({ | ||
| 65 | terminalId: "term-2", | ||
| 66 | status: "exited", | ||
| 67 | cwd: "/workspace/exited", | ||
| 68 | updatedAt: "2026-04-15T20:06:00.000Z", | ||
| 69 | }), | ||
| 70 | ], | ||
| 71 | workspaceRoot: "/workspace/root", | ||
| 72 | }), | ||
| 73 | ).toEqual([ | ||
| 74 | { | ||
| 75 | terminalId: "term-3", | ||
| 76 | cwd: "/workspace/feature", | ||
| 77 | status: "running", | ||
| 78 | hasRunningSubprocess: false, | ||
| 79 | displayLabel: "Terminal 3", | ||
| 80 | updatedAt: "2026-04-15T20:05:00.000Z", | ||
| 81 | }, | ||
| 82 | ]); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("keeps the current terminal visible even if it is no longer running", () => { | ||
| 86 | expect( | ||
| 87 | buildTerminalMenuSessions({ | ||
| 88 | knownSessions: [], | ||
| 89 | workspaceRoot: "/workspace/root", | ||
| 90 | currentSession: { | ||
| 91 | terminalId: "term-4", | ||
| 92 | cwd: "/workspace/exited", | ||
| 93 | status: "exited", | ||
| 94 | hasRunningSubprocess: false, | ||
| 95 | displayLabel: "Terminal 4", | ||
| 96 | updatedAt: "2026-04-15T20:07:00.000Z", | ||
| 97 | }, | ||
| 98 | }), | ||
| 99 | ).toEqual([ | ||
| 100 | { | ||
| 101 | terminalId: "term-4", | ||
| 102 | cwd: "/workspace/exited", | ||
| 103 | status: "exited", | ||
| 104 | hasRunningSubprocess: false, | ||
| 105 | displayLabel: "Terminal 4", | ||
| 106 | updatedAt: "2026-04-15T20:07:00.000Z", | ||
| 107 | }, | ||
| 108 | ]); | ||
| 109 | }); | ||
| 110 | }); | ||
| 111 | |||
| 112 | describe("nextTerminalId", () => { | ||
| 113 | it("uses the primary id when no terminals are listed yet", () => { | ||
| 114 | expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID); | ||
| 115 | }); | ||
| 116 | |||
| 117 | it("allocates term-2 when only the primary shell exists", () => { | ||
| 118 | expect(nextTerminalId([DEFAULT_TERMINAL_ID])).toBe("term-2"); | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | describe("nextOpenTerminalId", () => { | ||
| 123 | it("matches nextTerminalId when not on a terminal route", () => { | ||
| 124 | expect(nextOpenTerminalId({ listedTerminalIds: [] })).toBe(DEFAULT_TERMINAL_ID); | ||
| 125 | expect(nextOpenTerminalId({ listedTerminalIds: [DEFAULT_TERMINAL_ID] })).toBe("term-2"); | ||
| 126 | }); | ||
| 127 | |||
| 128 | it("avoids the mounted primary tab when the session list is still empty", () => { | ||
| 129 | expect( | ||
| 130 | nextOpenTerminalId({ | ||
| 131 | listedTerminalIds: [], | ||
| 132 | activeRouteTerminalId: DEFAULT_TERMINAL_ID, | ||
| 133 | }), | ||
| 134 | ).toBe("term-2"); | ||
| 135 | }); | ||
| 136 | |||
| 137 | it("does not double-count when the route id is already listed", () => { | ||
| 138 | expect( | ||
| 139 | nextOpenTerminalId({ | ||
| 140 | listedTerminalIds: [DEFAULT_TERMINAL_ID], | ||
| 141 | activeRouteTerminalId: DEFAULT_TERMINAL_ID, | ||
| 142 | }), | ||
| 143 | ).toBe("term-2"); | ||
| 144 | }); | ||
| 145 | }); | ||
| 146 | |||
| 147 | describe("resolveProjectScriptTerminalId", () => { | ||
| 148 | it("reuses the default shell when no terminal is running", () => { | ||
| 149 | expect( | ||
| 150 | resolveProjectScriptTerminalId({ | ||
| 151 | existingTerminalIds: [DEFAULT_TERMINAL_ID], | ||
| 152 | hasRunningTerminal: false, | ||
| 153 | }), | ||
| 154 | ).toBe(DEFAULT_TERMINAL_ID); | ||
| 155 | }); | ||
| 156 | |||
| 157 | it("opens a new terminal when a shell is already running", () => { | ||
| 158 | expect( | ||
| 159 | resolveProjectScriptTerminalId({ | ||
| 160 | existingTerminalIds: [DEFAULT_TERMINAL_ID, "term-2", "term-4"], | ||
| 161 | hasRunningTerminal: true, | ||
| 162 | }), | ||
| 163 | ).toBe("term-3"); | ||
| 164 | }); | ||
| 165 | }); | ||
apps/mobile/src/features/terminal/terminalMenu.ts created+139| ... | @@ -0,0 +1,139 @@ | ||
| 1 | import type { KnownTerminalSession } from "@t3tools/client-runtime"; | ||
| 2 | import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts"; | ||
| 3 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | ||
| 4 | import * as Arr from "effect/Array"; | ||
| 5 | import * as Order from "effect/Order"; | ||
| 6 | |||
| 7 | export { | ||
| 8 | getTerminalLabel, | ||
| 9 | nextTerminalId, | ||
| 10 | resolveTerminalSessionLabel, | ||
| 11 | } from "@t3tools/shared/terminalLabels"; | ||
| 12 | |||
| 13 | export interface TerminalMenuSession { | ||
| 14 | readonly terminalId: string; | ||
| 15 | readonly cwd: string | null; | ||
| 16 | readonly status: "starting" | "running" | "exited" | "error" | "closed"; | ||
| 17 | readonly hasRunningSubprocess: boolean; | ||
| 18 | /** Server-authoritative title with the same fallback rules as web. */ | ||
| 19 | readonly displayLabel: string; | ||
| 20 | readonly updatedAt: string | null; | ||
| 21 | } | ||
| 22 | |||
| 23 | const terminalMenuSessionOrder = Order.make<TerminalMenuSession>((left, right) => { | ||
| 24 | const comparison = left.terminalId.localeCompare(right.terminalId, undefined, { numeric: true }); | ||
| 25 | if (comparison === 0) { | ||
| 26 | return 0; | ||
| 27 | } | ||
| 28 | return comparison < 0 ? -1 : 1; | ||
| 29 | }); | ||
| 30 | |||
| 31 | export function basename(input: string | null): string | null { | ||
| 32 | if (!input) { | ||
| 33 | return null; | ||
| 34 | } | ||
| 35 | |||
| 36 | const normalized = input.replace(/\/+$/, ""); | ||
| 37 | if (normalized.length === 0) { | ||
| 38 | return "/"; | ||
| 39 | } | ||
| 40 | |||
| 41 | const segments = normalized.split("/"); | ||
| 42 | return segments[segments.length - 1] ?? normalized; | ||
| 43 | } | ||
| 44 | |||
| 45 | export function getTerminalStatusLabel(input: { | ||
| 46 | readonly status: TerminalMenuSession["status"]; | ||
| 47 | readonly hasRunningSubprocess?: boolean; | ||
| 48 | }): string { | ||
| 49 | if (input.status === "running") { | ||
| 50 | return input.hasRunningSubprocess ? "Task running" : "Ready"; | ||
| 51 | } | ||
| 52 | if (input.status === "starting") { | ||
| 53 | return "Starting"; | ||
| 54 | } | ||
| 55 | if (input.status === "exited") { | ||
| 56 | return "Exited"; | ||
| 57 | } | ||
| 58 | if (input.status === "error") { | ||
| 59 | return "Error"; | ||
| 60 | } | ||
| 61 | |||
| 62 | return "Not started"; | ||
| 63 | } | ||
| 64 | |||
| 65 | /** | ||
| 66 | * Picks an id for "open another shell". Counts the terminal screen already mounted | ||
| 67 | * (`activeRouteTerminalId`) as occupied so an empty session list on the primary route | ||
| 68 | * still advances to `term-2` instead of `replace`-navigating to the same `default` tab. | ||
| 69 | */ | ||
| 70 | export function nextOpenTerminalId(input: { | ||
| 71 | readonly listedTerminalIds: ReadonlyArray<string>; | ||
| 72 | readonly activeRouteTerminalId?: string | null; | ||
| 73 | }): string { | ||
| 74 | const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0); | ||
| 75 | const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null; | ||
| 76 | |||
| 77 | if (!routeId || listed.includes(routeId)) { | ||
| 78 | return nextTerminalId(listed); | ||
| 79 | } | ||
| 80 | |||
| 81 | return nextTerminalId([...listed, routeId]); | ||
| 82 | } | ||
| 83 | |||
| 84 | export function buildTerminalMenuSessions(input: { | ||
| 85 | readonly knownSessions: ReadonlyArray<KnownTerminalSession>; | ||
| 86 | readonly workspaceRoot: string | null; | ||
| 87 | readonly currentSession?: TerminalMenuSession | null; | ||
| 88 | }): ReadonlyArray<TerminalMenuSession> { | ||
| 89 | const sessionsById = new Map<string, TerminalMenuSession>(); | ||
| 90 | |||
| 91 | for (const session of input.knownSessions) { | ||
| 92 | if ( | ||
| 93 | session.state.status !== "running" && | ||
| 94 | session.state.status !== "starting" && | ||
| 95 | session.target.terminalId !== input.currentSession?.terminalId | ||
| 96 | ) { | ||
| 97 | continue; | ||
| 98 | } | ||
| 99 | |||
| 100 | sessionsById.set(session.target.terminalId, { | ||
| 101 | terminalId: session.target.terminalId, | ||
| 102 | cwd: session.state.summary?.cwd ?? input.workspaceRoot, | ||
| 103 | status: session.state.status, | ||
| 104 | hasRunningSubprocess: session.state.hasRunningSubprocess, | ||
| 105 | displayLabel: resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | ||
| 106 | updatedAt: session.state.updatedAt, | ||
| 107 | }); | ||
| 108 | } | ||
| 109 | |||
| 110 | if (input.currentSession && !sessionsById.has(input.currentSession.terminalId)) { | ||
| 111 | sessionsById.set(input.currentSession.terminalId, input.currentSession); | ||
| 112 | } | ||
| 113 | |||
| 114 | return Arr.sort(sessionsById.values(), terminalMenuSessionOrder); | ||
| 115 | } | ||
| 116 | |||
| 117 | export function resolveProjectScriptTerminalId(input: { | ||
| 118 | readonly existingTerminalIds: ReadonlyArray<string>; | ||
| 119 | readonly hasRunningTerminal: boolean; | ||
| 120 | }): string { | ||
| 121 | if (!input.hasRunningTerminal) { | ||
| 122 | return DEFAULT_TERMINAL_ID; | ||
| 123 | } | ||
| 124 | |||
| 125 | return nextTerminalId(input.existingTerminalIds); | ||
| 126 | } | ||
| 127 | |||
| 128 | export function projectScriptMenuLabel(script: ProjectScript): string { | ||
| 129 | return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name; | ||
| 130 | } | ||
| 131 | |||
| 132 | export function projectScriptMenuIcon(icon: ProjectScript["icon"]) { | ||
| 133 | if (icon === "test") return "flask"; | ||
| 134 | if (icon === "lint") return "checklist"; | ||
| 135 | if (icon === "configure") return "wrench.and.screwdriver"; | ||
| 136 | if (icon === "build") return "hammer"; | ||
| 137 | if (icon === "debug") return "ladybug"; | ||
| 138 | return "play"; | ||
| 139 | } | ||
apps/mobile/src/features/terminal/terminalPreferences.test.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | DEFAULT_TERMINAL_FONT_SIZE, | ||
| 5 | MAX_TERMINAL_FONT_SIZE, | ||
| 6 | MIN_TERMINAL_FONT_SIZE, | ||
| 7 | normalizeTerminalFontSize, | ||
| 8 | } from "./terminalPreferences"; | ||
| 9 | |||
| 10 | describe("normalizeTerminalFontSize", () => { | ||
| 11 | it("returns the default size for missing or invalid values", () => { | ||
| 12 | expect(normalizeTerminalFontSize(undefined)).toBe(DEFAULT_TERMINAL_FONT_SIZE); | ||
| 13 | expect(normalizeTerminalFontSize(null)).toBe(DEFAULT_TERMINAL_FONT_SIZE); | ||
| 14 | expect(normalizeTerminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); | ||
| 15 | }); | ||
| 16 | |||
| 17 | it("clamps below the minimum", () => { | ||
| 18 | expect(normalizeTerminalFontSize(MIN_TERMINAL_FONT_SIZE - 4)).toBe(MIN_TERMINAL_FONT_SIZE); | ||
| 19 | }); | ||
| 20 | |||
| 21 | it("clamps above the maximum", () => { | ||
| 22 | expect(normalizeTerminalFontSize(MAX_TERMINAL_FONT_SIZE + 4)).toBe(MAX_TERMINAL_FONT_SIZE); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("preserves in-range values", () => { | ||
| 26 | expect(normalizeTerminalFontSize(9.5)).toBe(9.5); | ||
| 27 | }); | ||
| 28 | }); | ||
apps/mobile/src/features/terminal/terminalPreferences.ts created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | export const DEFAULT_TERMINAL_FONT_SIZE = 10; | ||
| 2 | export const TERMINAL_FONT_SIZE_STEP = 0.5; | ||
| 3 | export const MIN_TERMINAL_FONT_SIZE = 6; | ||
| 4 | export const MAX_TERMINAL_FONT_SIZE = 14; | ||
| 5 | |||
| 6 | export function normalizeTerminalFontSize(value: number | null | undefined): number { | ||
| 7 | if (typeof value !== "number" || !Number.isFinite(value)) { | ||
| 8 | return DEFAULT_TERMINAL_FONT_SIZE; | ||
| 9 | } | ||
| 10 | |||
| 11 | return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, value)); | ||
| 12 | } | ||
apps/mobile/src/features/terminal/terminalRouteBootstrap.ts created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | export function resolveTerminalRouteBootstrap(input: { | ||
| 2 | readonly hasThread: boolean; | ||
| 3 | readonly hasWorkspaceRoot: boolean; | ||
| 4 | readonly hasOpened: boolean; | ||
| 5 | readonly requestedTerminalId: string | null; | ||
| 6 | readonly currentTerminalId: string; | ||
| 7 | readonly runningTerminalId: string | null; | ||
| 8 | readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed"; | ||
| 9 | /** True once the attach stream has populated scrollback (`buffer` non-empty), not merely metadata. */ | ||
| 10 | readonly hasCurrentTerminalHydration: boolean; | ||
| 11 | }): | ||
| 12 | | { readonly kind: "idle" } | ||
| 13 | | { readonly kind: "redirect"; readonly terminalId: string } | ||
| 14 | | { readonly kind: "open" } { | ||
| 15 | if (!input.hasThread || !input.hasWorkspaceRoot || input.hasOpened) { | ||
| 16 | return { kind: "idle" }; | ||
| 17 | } | ||
| 18 | |||
| 19 | if ( | ||
| 20 | input.requestedTerminalId === null && | ||
| 21 | input.runningTerminalId !== null && | ||
| 22 | input.runningTerminalId !== input.currentTerminalId | ||
| 23 | ) { | ||
| 24 | return { kind: "redirect", terminalId: input.runningTerminalId }; | ||
| 25 | } | ||
| 26 | |||
| 27 | if ( | ||
| 28 | (input.currentTerminalStatus === "running" || input.currentTerminalStatus === "starting") && | ||
| 29 | input.hasCurrentTerminalHydration | ||
| 30 | ) { | ||
| 31 | return { kind: "idle" }; | ||
| 32 | } | ||
| 33 | |||
| 34 | return { kind: "open" }; | ||
| 35 | } | ||
apps/mobile/src/features/terminal/terminalTheme.test.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme"; | ||
| 4 | |||
| 5 | describe("getPierreTerminalTheme", () => { | ||
| 6 | it("returns the Pierre light terminal palette", () => { | ||
| 7 | expect(getPierreTerminalTheme("light")).toMatchObject({ | ||
| 8 | background: "#f2f2f7", | ||
| 9 | foreground: "#6C6C71", | ||
| 10 | cursorForeground: "#009fff", | ||
| 11 | cursorBackground: "#f2f2f7", | ||
| 12 | }); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("returns the Pierre dark terminal palette", () => { | ||
| 16 | expect(getPierreTerminalTheme("dark")).toMatchObject({ | ||
| 17 | background: "#0a0a0a", | ||
| 18 | foreground: "#adadb1", | ||
| 19 | cursorForeground: "#009fff", | ||
| 20 | cursorBackground: "#0a0a0a", | ||
| 21 | }); | ||
| 22 | }); | ||
| 23 | }); | ||
| 24 | |||
| 25 | describe("buildGhosttyThemeConfig", () => { | ||
| 26 | it("serializes theme colors into a ghostty config file", () => { | ||
| 27 | const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark")); | ||
| 28 | |||
| 29 | expect(config).toContain("background = #0a0a0a"); | ||
| 30 | expect(config).toContain("foreground = #adadb1"); | ||
| 31 | expect(config).toContain("cursor-color = #009fff"); | ||
| 32 | expect(config).toContain("palette = 0=#141415"); | ||
| 33 | expect(config).toContain("palette = 15=#c6c6c8"); | ||
| 34 | expect(config.endsWith("\n")).toBe(true); | ||
| 35 | }); | ||
| 36 | }); | ||
apps/mobile/src/features/terminal/terminalTheme.ts created+86| ... | @@ -0,0 +1,86 @@ | ||
| 1 | export type TerminalAppearanceScheme = "light" | "dark"; | ||
| 2 | |||
| 3 | export interface TerminalTheme { | ||
| 4 | readonly background: string; | ||
| 5 | readonly foreground: string; | ||
| 6 | readonly mutedForeground: string; | ||
| 7 | readonly border: string; | ||
| 8 | readonly cursorForeground: string; | ||
| 9 | readonly cursorBackground: string; | ||
| 10 | readonly palette: readonly string[]; | ||
| 11 | } | ||
| 12 | |||
| 13 | const PIERRE_LIGHT_THEME: TerminalTheme = { | ||
| 14 | // Pierre terminal palette with the app's shared screen background. | ||
| 15 | background: "#f2f2f7", | ||
| 16 | foreground: "#6C6C71", | ||
| 17 | mutedForeground: "#8E8E95", | ||
| 18 | border: "#eeeeef", | ||
| 19 | cursorForeground: "#009fff", | ||
| 20 | cursorBackground: "#f2f2f7", | ||
| 21 | palette: [ | ||
| 22 | "#1F1F21", | ||
| 23 | "#ff2e3f", | ||
| 24 | "#0dbe4e", | ||
| 25 | "#ffca00", | ||
| 26 | "#009fff", | ||
| 27 | "#c635e4", | ||
| 28 | "#08c0ef", | ||
| 29 | "#c6c6c8", | ||
| 30 | "#1F1F21", | ||
| 31 | "#ff2e3f", | ||
| 32 | "#0dbe4e", | ||
| 33 | "#ffca00", | ||
| 34 | "#009fff", | ||
| 35 | "#c635e4", | ||
| 36 | "#08c0ef", | ||
| 37 | "#c6c6c8", | ||
| 38 | ], | ||
| 39 | }; | ||
| 40 | |||
| 41 | const PIERRE_DARK_THEME: TerminalTheme = { | ||
| 42 | // Pierre terminal palette with the app's shared screen background. | ||
| 43 | background: "#0a0a0a", | ||
| 44 | foreground: "#adadb1", | ||
| 45 | mutedForeground: "#8E8E95", | ||
| 46 | border: "#2e2e30", | ||
| 47 | cursorForeground: "#009fff", | ||
| 48 | cursorBackground: "#0a0a0a", | ||
| 49 | palette: [ | ||
| 50 | "#141415", | ||
| 51 | "#ff2e3f", | ||
| 52 | "#0dbe4e", | ||
| 53 | "#ffca00", | ||
| 54 | "#009fff", | ||
| 55 | "#c635e4", | ||
| 56 | "#08c0ef", | ||
| 57 | "#c6c6c8", | ||
| 58 | "#141415", | ||
| 59 | "#ff2e3f", | ||
| 60 | "#0dbe4e", | ||
| 61 | "#ffca00", | ||
| 62 | "#009fff", | ||
| 63 | "#c635e4", | ||
| 64 | "#08c0ef", | ||
| 65 | "#c6c6c8", | ||
| 66 | ], | ||
| 67 | }; | ||
| 68 | |||
| 69 | export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { | ||
| 70 | return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; | ||
| 71 | } | ||
| 72 | |||
| 73 | export function buildGhosttyThemeConfig(theme: TerminalTheme): string { | ||
| 74 | const lines = [ | ||
| 75 | `background = ${theme.background}`, | ||
| 76 | `foreground = ${theme.foreground}`, | ||
| 77 | `cursor-color = ${theme.cursorForeground}`, | ||
| 78 | `cursor-text = ${theme.cursorBackground}`, | ||
| 79 | ]; | ||
| 80 | |||
| 81 | for (const [index, color] of theme.palette.entries()) { | ||
| 82 | lines.push(`palette = ${index}=${color}`); | ||
| 83 | } | ||
| 84 | |||
| 85 | return `${lines.join("\n")}\n`; | ||
| 86 | } | ||
apps/mobile/src/features/terminal/terminalUiState.test.ts created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | import { beforeEach, describe, expect, it } from "vitest"; | ||
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | cacheTerminalFontSize, | ||
| 6 | cacheTerminalGridSize, | ||
| 7 | getCachedTerminalFontSize, | ||
| 8 | getCachedTerminalGridSize, | ||
| 9 | resetTerminalUiStateCaches, | ||
| 10 | } from "./terminalUiState"; | ||
| 11 | |||
| 12 | describe("terminalUiState", () => { | ||
| 13 | beforeEach(() => { | ||
| 14 | resetTerminalUiStateCaches(); | ||
| 15 | }); | ||
| 16 | |||
| 17 | it("caches terminal font size using the shared normalization rules", () => { | ||
| 18 | expect(getCachedTerminalFontSize()).toBeNull(); | ||
| 19 | expect(cacheTerminalFontSize(8.5)).toBe(8.5); | ||
| 20 | expect(getCachedTerminalFontSize()).toBe(8.5); | ||
| 21 | expect(cacheTerminalFontSize(100)).toBe(14); | ||
| 22 | expect(getCachedTerminalFontSize()).toBe(14); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("stores terminal grid sizes per terminal target", () => { | ||
| 26 | const primaryTarget = { | ||
| 27 | environmentId: EnvironmentId.make("env-1"), | ||
| 28 | threadId: ThreadId.make("thread-1"), | ||
| 29 | terminalId: "default", | ||
| 30 | }; | ||
| 31 | const otherTarget = { | ||
| 32 | environmentId: EnvironmentId.make("env-1"), | ||
| 33 | threadId: ThreadId.make("thread-1"), | ||
| 34 | terminalId: "term-2", | ||
| 35 | }; | ||
| 36 | |||
| 37 | expect(getCachedTerminalGridSize(primaryTarget)).toBeNull(); | ||
| 38 | expect( | ||
| 39 | cacheTerminalGridSize(primaryTarget, { | ||
| 40 | cols: 107.9, | ||
| 41 | rows: 33.2, | ||
| 42 | }), | ||
| 43 | ).toEqual({ | ||
| 44 | cols: 107, | ||
| 45 | rows: 33, | ||
| 46 | }); | ||
| 47 | expect(getCachedTerminalGridSize(primaryTarget)).toEqual({ | ||
| 48 | cols: 107, | ||
| 49 | rows: 33, | ||
| 50 | }); | ||
| 51 | expect(getCachedTerminalGridSize(otherTarget)).toBeNull(); | ||
| 52 | }); | ||
| 53 | }); | ||
apps/mobile/src/features/terminal/terminalUiState.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { DEFAULT_TERMINAL_FONT_SIZE, normalizeTerminalFontSize } from "./terminalPreferences"; | ||
| 4 | |||
| 5 | export interface TerminalGridSize { | ||
| 6 | readonly cols: number; | ||
| 7 | readonly rows: number; | ||
| 8 | } | ||
| 9 | |||
| 10 | export interface TerminalUiStateTarget { | ||
| 11 | readonly environmentId: EnvironmentId; | ||
| 12 | readonly threadId: ThreadId; | ||
| 13 | readonly terminalId: string; | ||
| 14 | } | ||
| 15 | |||
| 16 | const terminalGridSizeCache = new Map<string, TerminalGridSize>(); | ||
| 17 | let cachedTerminalFontSize: number | null = null; | ||
| 18 | |||
| 19 | function terminalUiStateKey(target: TerminalUiStateTarget): string { | ||
| 20 | return `${target.environmentId}:${target.threadId}:${target.terminalId}`; | ||
| 21 | } | ||
| 22 | |||
| 23 | export function getCachedTerminalFontSize(): number | null { | ||
| 24 | return cachedTerminalFontSize; | ||
| 25 | } | ||
| 26 | |||
| 27 | export function cacheTerminalFontSize(value: number | null | undefined): number { | ||
| 28 | const normalized = normalizeTerminalFontSize(value ?? DEFAULT_TERMINAL_FONT_SIZE); | ||
| 29 | cachedTerminalFontSize = normalized; | ||
| 30 | return normalized; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function getCachedTerminalGridSize(target: TerminalUiStateTarget): TerminalGridSize | null { | ||
| 34 | return terminalGridSizeCache.get(terminalUiStateKey(target)) ?? null; | ||
| 35 | } | ||
| 36 | |||
| 37 | export function cacheTerminalGridSize( | ||
| 38 | target: TerminalUiStateTarget, | ||
| 39 | size: TerminalGridSize, | ||
| 40 | ): TerminalGridSize { | ||
| 41 | const normalized = { | ||
| 42 | cols: Math.max(1, Math.floor(size.cols)), | ||
| 43 | rows: Math.max(1, Math.floor(size.rows)), | ||
| 44 | }; | ||
| 45 | terminalGridSizeCache.set(terminalUiStateKey(target), normalized); | ||
| 46 | return normalized; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function resetTerminalUiStateCaches() { | ||
| 50 | cachedTerminalFontSize = null; | ||
| 51 | terminalGridSizeCache.clear(); | ||
| 52 | } | ||
apps/mobile/src/features/threads/ComposerCommandPopover.tsx created+211| ... | @@ -0,0 +1,211 @@ | ||
| 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | ||
| 2 | import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; | ||
| 3 | import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; | ||
| 4 | import { SymbolView } from "expo-symbols"; | ||
| 5 | import { memo } from "react"; | ||
| 6 | import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; | ||
| 7 | |||
| 8 | import { AppText as Text } from "../../components/AppText"; | ||
| 9 | |||
| 10 | export type ComposerCommandItem = | ||
| 11 | | { | ||
| 12 | readonly id: string; | ||
| 13 | readonly type: "path"; | ||
| 14 | readonly path: string; | ||
| 15 | readonly kind: "file" | "directory"; | ||
| 16 | readonly label: string; | ||
| 17 | readonly description: string; | ||
| 18 | } | ||
| 19 | | { | ||
| 20 | readonly id: string; | ||
| 21 | readonly type: "slash-command"; | ||
| 22 | readonly command: string; | ||
| 23 | readonly label: string; | ||
| 24 | readonly description: string; | ||
| 25 | } | ||
| 26 | | { | ||
| 27 | readonly id: string; | ||
| 28 | readonly type: "provider-slash-command"; | ||
| 29 | readonly command: ServerProviderSlashCommand; | ||
| 30 | readonly label: string; | ||
| 31 | readonly description: string; | ||
| 32 | } | ||
| 33 | | { | ||
| 34 | readonly id: string; | ||
| 35 | readonly type: "skill"; | ||
| 36 | readonly skill: ServerProviderSkill; | ||
| 37 | readonly label: string; | ||
| 38 | readonly description: string; | ||
| 39 | }; | ||
| 40 | |||
| 41 | interface ComposerCommandPopoverProps { | ||
| 42 | readonly items: ReadonlyArray<ComposerCommandItem>; | ||
| 43 | readonly triggerKind: ComposerTriggerKind | null; | ||
| 44 | readonly isLoading: boolean; | ||
| 45 | readonly onSelect: (item: ComposerCommandItem) => void; | ||
| 46 | } | ||
| 47 | |||
| 48 | function PopoverSurface(props: { | ||
| 49 | readonly children: React.ReactNode; | ||
| 50 | readonly isDarkMode: boolean; | ||
| 51 | readonly style?: ViewStyle; | ||
| 52 | }) { | ||
| 53 | const baseStyle: ViewStyle = { | ||
| 54 | borderRadius: 16, | ||
| 55 | overflow: "hidden", | ||
| 56 | ...props.style, | ||
| 57 | }; | ||
| 58 | |||
| 59 | if (isLiquidGlassSupported) { | ||
| 60 | return ( | ||
| 61 | <LiquidGlassView | ||
| 62 | effect="clear" | ||
| 63 | interactive={false} | ||
| 64 | tintColor={props.isDarkMode ? "rgba(30,30,32,0.95)" : "rgba(255,255,255,0.92)"} | ||
| 65 | colorScheme={props.isDarkMode ? "dark" : "light"} | ||
| 66 | style={baseStyle} | ||
| 67 | > | ||
| 68 | {props.children} | ||
| 69 | </LiquidGlassView> | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | |||
| 73 | return ( | ||
| 74 | <View | ||
| 75 | style={[ | ||
| 76 | baseStyle, | ||
| 77 | { | ||
| 78 | backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", | ||
| 79 | borderWidth: 1, | ||
| 80 | borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", | ||
| 81 | }, | ||
| 82 | ]} | ||
| 83 | > | ||
| 84 | {props.children} | ||
| 85 | </View> | ||
| 86 | ); | ||
| 87 | } | ||
| 88 | |||
| 89 | function itemIcon(item: ComposerCommandItem) { | ||
| 90 | switch (item.type) { | ||
| 91 | case "path": | ||
| 92 | return item.kind === "directory" ? ("folder" as const) : ("doc" as const); | ||
| 93 | case "slash-command": | ||
| 94 | case "provider-slash-command": | ||
| 95 | return "terminal" as const; | ||
| 96 | case "skill": | ||
| 97 | return "cube" as const; | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | function groupLabel(triggerKind: ComposerTriggerKind | null): string | null { | ||
| 102 | switch (triggerKind) { | ||
| 103 | case "slash-command": | ||
| 104 | return "Commands"; | ||
| 105 | case "skill": | ||
| 106 | return "Skills"; | ||
| 107 | case "path": | ||
| 108 | return "Files"; | ||
| 109 | default: | ||
| 110 | return null; | ||
| 111 | } | ||
| 112 | } | ||
| 113 | |||
| 114 | function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string { | ||
| 115 | if (isLoading) { | ||
| 116 | return triggerKind === "path" ? "Searching files…" : "Loading…"; | ||
| 117 | } | ||
| 118 | switch (triggerKind) { | ||
| 119 | case "path": | ||
| 120 | return "No matching files or folders."; | ||
| 121 | case "skill": | ||
| 122 | return "No skills found."; | ||
| 123 | case "slash-command": | ||
| 124 | return "No matching commands."; | ||
| 125 | default: | ||
| 126 | return "No results."; | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | const CommandRow = memo(function CommandRow(props: { | ||
| 131 | readonly item: ComposerCommandItem; | ||
| 132 | readonly onPress: () => void; | ||
| 133 | readonly isLast: boolean; | ||
| 134 | }) { | ||
| 135 | const iconName = itemIcon(props.item); | ||
| 136 | const iconColor = "#a1a1aa"; | ||
| 137 | |||
| 138 | return ( | ||
| 139 | <Pressable | ||
| 140 | onPress={props.onPress} | ||
| 141 | style={({ pressed }) => ({ | ||
| 142 | flexDirection: "row", | ||
| 143 | alignItems: "center", | ||
| 144 | paddingHorizontal: 14, | ||
| 145 | paddingVertical: 10, | ||
| 146 | gap: 10, | ||
| 147 | opacity: pressed ? 0.6 : 1, | ||
| 148 | borderBottomWidth: props.isLast ? 0 : 0.5, | ||
| 149 | borderBottomColor: "rgba(255,255,255,0.1)", | ||
| 150 | })} | ||
| 151 | > | ||
| 152 | <SymbolView name={iconName} size={14} tintColor={iconColor} type="monochrome" /> | ||
| 153 | <Text | ||
| 154 | className="text-[14px] font-t3-medium text-foreground" | ||
| 155 | numberOfLines={1} | ||
| 156 | style={{ flexShrink: 0 }} | ||
| 157 | > | ||
| 158 | {props.item.label} | ||
| 159 | </Text> | ||
| 160 | {props.item.description ? ( | ||
| 161 | <Text numberOfLines={1} style={{ flex: 1, minWidth: 0, fontSize: 12, color: "#a1a1aa" }}> | ||
| 162 | {props.item.description} | ||
| 163 | </Text> | ||
| 164 | ) : null} | ||
| 165 | </Pressable> | ||
| 166 | ); | ||
| 167 | }); | ||
| 168 | |||
| 169 | export const ComposerCommandPopover = memo(function ComposerCommandPopover( | ||
| 170 | props: ComposerCommandPopoverProps, | ||
| 171 | ) { | ||
| 172 | const isDarkMode = useColorScheme() === "dark"; | ||
| 173 | const label = groupLabel(props.triggerKind); | ||
| 174 | |||
| 175 | return ( | ||
| 176 | <PopoverSurface isDarkMode={isDarkMode}> | ||
| 177 | {label ? ( | ||
| 178 | <View style={{ paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 }}> | ||
| 179 | <Text | ||
| 180 | className="text-[10px] font-t3-bold text-foreground-muted" | ||
| 181 | style={{ letterSpacing: 0.8, textTransform: "uppercase" }} | ||
| 182 | > | ||
| 183 | {label} | ||
| 184 | </Text> | ||
| 185 | </View> | ||
| 186 | ) : null} | ||
| 187 | {props.items.length > 0 ? ( | ||
| 188 | <ScrollView | ||
| 189 | style={{ maxHeight: 180 }} | ||
| 190 | keyboardShouldPersistTaps="always" | ||
| 191 | showsVerticalScrollIndicator={false} | ||
| 192 | > | ||
| 193 | {props.items.map((item, index) => ( | ||
| 194 | <CommandRow | ||
| 195 | key={item.id} | ||
| 196 | item={item} | ||
| 197 | onPress={() => props.onSelect(item)} | ||
| 198 | isLast={index === props.items.length - 1} | ||
| 199 | /> | ||
| 200 | ))} | ||
| 201 | </ScrollView> | ||
| 202 | ) : ( | ||
| 203 | <View style={{ paddingHorizontal: 14, paddingVertical: 10 }}> | ||
| 204 | <Text className="text-[12px] text-foreground-tertiary"> | ||
| 205 | {emptyText(props.triggerKind, props.isLoading)} | ||
| 206 | </Text> | ||
| 207 | </View> | ||
| 208 | )} | ||
| 209 | </PopoverSurface> | ||
| 210 | ); | ||
| 211 | }); | ||
apps/mobile/src/features/threads/GitActionProgressOverlay.tsx created+121| ... | @@ -0,0 +1,121 @@ | ||
| 1 | import * as Haptics from "expo-haptics"; | ||
| 2 | import { SymbolView } from "expo-symbols"; | ||
| 3 | import { useCallback, useEffect, useRef } from "react"; | ||
| 4 | import { ActivityIndicator, Linking, Pressable, View } from "react-native"; | ||
| 5 | import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; | ||
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 7 | |||
| 8 | import { AppText as Text } from "../../components/AppText"; | ||
| 9 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 10 | import type { GitActionProgress } from "../../state/use-vcs-action-state"; | ||
| 11 | |||
| 12 | export function GitActionProgressOverlay(props: { | ||
| 13 | readonly progress: GitActionProgress; | ||
| 14 | readonly onDismiss: () => void; | ||
| 15 | }) { | ||
| 16 | const { progress, onDismiss } = props; | ||
| 17 | const insets = useSafeAreaInsets(); | ||
| 18 | const prevPhaseRef = useRef(progress.phase); | ||
| 19 | |||
| 20 | useEffect(() => { | ||
| 21 | const prev = prevPhaseRef.current; | ||
| 22 | prevPhaseRef.current = progress.phase; | ||
| 23 | |||
| 24 | if (prev === "running" && progress.phase === "success") { | ||
| 25 | Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); | ||
| 26 | } else if (prev === "running" && progress.phase === "error") { | ||
| 27 | Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); | ||
| 28 | } | ||
| 29 | }, [progress.phase]); | ||
| 30 | |||
| 31 | const handlePress = useCallback(() => { | ||
| 32 | if (progress.prUrl) { | ||
| 33 | void Linking.openURL(progress.prUrl); | ||
| 34 | return; | ||
| 35 | } | ||
| 36 | if (progress.phase === "success" || progress.phase === "error") { | ||
| 37 | onDismiss(); | ||
| 38 | } | ||
| 39 | }, [onDismiss, progress.phase, progress.prUrl]); | ||
| 40 | |||
| 41 | if (progress.phase === "idle") { | ||
| 42 | return null; | ||
| 43 | } | ||
| 44 | |||
| 45 | return ( | ||
| 46 | <Animated.View | ||
| 47 | entering={FadeIn.duration(200)} | ||
| 48 | exiting={FadeOut.duration(150)} | ||
| 49 | style={{ top: insets.top + 48, left: 12, right: 12, position: "absolute", zIndex: 100 }} | ||
| 50 | pointerEvents="box-none" | ||
| 51 | > | ||
| 52 | <Pressable onPress={handlePress}> | ||
| 53 | <OverlayContent progress={progress} /> | ||
| 54 | </Pressable> | ||
| 55 | </Animated.View> | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | function OverlayContent(props: { readonly progress: GitActionProgress }) { | ||
| 60 | const { progress } = props; | ||
| 61 | const iconColor = useThemeColor("--color-icon"); | ||
| 62 | |||
| 63 | const bgClass = | ||
| 64 | progress.phase === "error" | ||
| 65 | ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800" | ||
| 66 | : "bg-card border-border"; | ||
| 67 | |||
| 68 | return ( | ||
| 69 | <View | ||
| 70 | className={`flex-row items-center gap-2.5 rounded-2xl border px-3.5 py-3 shadow-lg shadow-black/10 ${bgClass}`} | ||
| 71 | > | ||
| 72 | <OverlayIcon phase={progress.phase} iconColor={iconColor} /> | ||
| 73 | |||
| 74 | <View className="flex-1 gap-0.5"> | ||
| 75 | {progress.label ? ( | ||
| 76 | <Text className="text-[13px] font-t3-bold text-foreground" numberOfLines={1}> | ||
| 77 | {progress.label} | ||
| 78 | </Text> | ||
| 79 | ) : null} | ||
| 80 | {progress.description ? ( | ||
| 81 | <Text className="text-[11px] text-foreground-muted" numberOfLines={1}> | ||
| 82 | {progress.description} | ||
| 83 | </Text> | ||
| 84 | ) : null} | ||
| 85 | </View> | ||
| 86 | |||
| 87 | {progress.prUrl ? ( | ||
| 88 | <SymbolView name="arrow.up.right" size={13} tintColor={iconColor} type="monochrome" /> | ||
| 89 | ) : null} | ||
| 90 | </View> | ||
| 91 | ); | ||
| 92 | } | ||
| 93 | |||
| 94 | function OverlayIcon(props: { | ||
| 95 | readonly phase: GitActionProgress["phase"]; | ||
| 96 | readonly iconColor: ReturnType<typeof useThemeColor>; | ||
| 97 | }) { | ||
| 98 | switch (props.phase) { | ||
| 99 | case "running": | ||
| 100 | return <ActivityIndicator size="small" />; | ||
| 101 | case "success": | ||
| 102 | return ( | ||
| 103 | <View className="h-6 w-6 items-center justify-center rounded-full bg-green-500"> | ||
| 104 | <SymbolView name="checkmark" size={12} tintColor="white" type="monochrome" /> | ||
| 105 | </View> | ||
| 106 | ); | ||
| 107 | case "error": | ||
| 108 | return ( | ||
| 109 | <View className="h-6 w-6 items-center justify-center rounded-full bg-red-500"> | ||
| 110 | <SymbolView | ||
| 111 | name="exclamationmark.triangle" | ||
| 112 | size={12} | ||
| 113 | tintColor="white" | ||
| 114 | type="monochrome" | ||
| 115 | /> | ||
| 116 | </View> | ||
| 117 | ); | ||
| 118 | default: | ||
| 119 | return null; | ||
| 120 | } | ||
| 121 | } | ||
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx created+494| ... | @@ -0,0 +1,494 @@ | ||
| 1 | import { useRouter } from "expo-router"; | ||
| 2 | import { TextInputWrapper } from "expo-paste-input"; | ||
| 3 | import { useCallback, useEffect, useMemo } from "react"; | ||
| 4 | import { View } from "react-native"; | ||
| 5 | import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller"; | ||
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 8 | |||
| 9 | import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | import { AppTextInput as TextInput } from "../../components/AppText"; | ||
| 12 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | ||
| 13 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | ||
| 14 | import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| 15 | |||
| 16 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; | ||
| 17 | import { buildThreadRoutePath } from "../../lib/routes"; | ||
| 18 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | ||
| 19 | import { useNativePaste } from "../../lib/useNativePaste"; | ||
| 20 | import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; | ||
| 21 | import { NewTaskSheetHeader } from "./NewTaskSheetHeader"; | ||
| 22 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | ||
| 23 | import { useProjectActions } from "./use-project-actions"; | ||
| 24 | |||
| 25 | function withModelSelectionOption( | ||
| 26 | selection: ModelSelection, | ||
| 27 | id: string, | ||
| 28 | value: string | boolean | undefined, | ||
| 29 | ): ModelSelection { | ||
| 30 | const options = (selection.options ?? []).filter((option) => option.id !== id); | ||
| 31 | return { | ||
| 32 | ...selection, | ||
| 33 | options: value === undefined ? options : [...options, { id, value }], | ||
| 34 | }; | ||
| 35 | } | ||
| 36 | |||
| 37 | export function NewTaskDraftScreen(props: { | ||
| 38 | readonly initialProjectRef?: { | ||
| 39 | readonly environmentId?: string; | ||
| 40 | readonly projectId?: string; | ||
| 41 | }; | ||
| 42 | }) { | ||
| 43 | const { projects } = useRemoteCatalog(); | ||
| 44 | const { onCreateThreadWithOptions } = useProjectActions(); | ||
| 45 | const flow = useNewTaskFlow(); | ||
| 46 | const router = useRouter(); | ||
| 47 | const insets = useSafeAreaInsets(); | ||
| 48 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); | ||
| 49 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); | ||
| 50 | const { logicalProjects, selectedProject, setProject } = flow; | ||
| 51 | |||
| 52 | const borderColor = useThemeColor("--color-border"); | ||
| 53 | |||
| 54 | useEffect(() => { | ||
| 55 | if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { | ||
| 56 | const directProject = | ||
| 57 | projects.find( | ||
| 58 | (project) => | ||
| 59 | project.environmentId === props.initialProjectRef?.environmentId && | ||
| 60 | project.id === props.initialProjectRef?.projectId, | ||
| 61 | ) ?? null; | ||
| 62 | |||
| 63 | if (directProject) { | ||
| 64 | setProject(directProject); | ||
| 65 | return; | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | if (selectedProject) { | ||
| 70 | return; | ||
| 71 | } | ||
| 72 | |||
| 73 | if (logicalProjects.length === 1) { | ||
| 74 | setProject(logicalProjects[0]!.project); | ||
| 75 | return; | ||
| 76 | } | ||
| 77 | |||
| 78 | router.replace("/new"); | ||
| 79 | }, [ | ||
| 80 | logicalProjects, | ||
| 81 | projects, | ||
| 82 | props.initialProjectRef?.environmentId, | ||
| 83 | props.initialProjectRef?.projectId, | ||
| 84 | router, | ||
| 85 | selectedProject, | ||
| 86 | setProject, | ||
| 87 | ]); | ||
| 88 | |||
| 89 | useEffect(() => { | ||
| 90 | if (!selectedProject) { | ||
| 91 | return; | ||
| 92 | } | ||
| 93 | void flow.loadBranches(); | ||
| 94 | }, [flow, selectedProject]); | ||
| 95 | |||
| 96 | const environmentMenuActions = useMemo( | ||
| 97 | () => | ||
| 98 | flow.environments.map((environment) => ({ | ||
| 99 | id: `environment:${environment.environmentId}`, | ||
| 100 | title: environment.environmentLabel, | ||
| 101 | state: | ||
| 102 | flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined, | ||
| 103 | })), | ||
| 104 | [flow.environments, flow.selectedEnvironmentId], | ||
| 105 | ); | ||
| 106 | |||
| 107 | const modelMenuActions = useMemo( | ||
| 108 | () => | ||
| 109 | flow.providerGroups.map((group) => ({ | ||
| 110 | id: `provider:${group.providerKey}`, | ||
| 111 | title: group.providerLabel, | ||
| 112 | subtitle: group.models.find( | ||
| 113 | (model) => | ||
| 114 | flow.selectedModel && | ||
| 115 | model.selection.instanceId === flow.selectedModel.instanceId && | ||
| 116 | model.selection.model === flow.selectedModel.model, | ||
| 117 | )?.label, | ||
| 118 | subactions: group.models.map((option) => ({ | ||
| 119 | id: `model:${option.key}`, | ||
| 120 | title: option.label, | ||
| 121 | state: | ||
| 122 | flow.selectedModel && | ||
| 123 | option.selection.instanceId === flow.selectedModel.instanceId && | ||
| 124 | option.selection.model === flow.selectedModel.model | ||
| 125 | ? ("on" as const) | ||
| 126 | : undefined, | ||
| 127 | })), | ||
| 128 | })), | ||
| 129 | [flow.providerGroups, flow.selectedModel], | ||
| 130 | ); | ||
| 131 | |||
| 132 | const optionsMenuActions = useMemo( | ||
| 133 | () => [ | ||
| 134 | { | ||
| 135 | id: "options-effort", | ||
| 136 | title: "Effort", | ||
| 137 | subtitle: `${flow.effort.charAt(0).toUpperCase()}${flow.effort.slice(1)}`, | ||
| 138 | subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ | ||
| 139 | id: `options:effort:${level}`, | ||
| 140 | title: `${level}${level === "high" ? " (default)" : ""}`, | ||
| 141 | state: flow.effort === level ? ("on" as const) : undefined, | ||
| 142 | })), | ||
| 143 | }, | ||
| 144 | { | ||
| 145 | id: "options-fast-mode", | ||
| 146 | title: "Fast Mode", | ||
| 147 | subtitle: flow.fastMode ? "On" : "Off", | ||
| 148 | subactions: ([false, true] as const).map((value) => ({ | ||
| 149 | id: `options:fast-mode:${value ? "on" : "off"}`, | ||
| 150 | title: value ? "On" : "Off", | ||
| 151 | state: flow.fastMode === value ? ("on" as const) : undefined, | ||
| 152 | })), | ||
| 153 | }, | ||
| 154 | { | ||
| 155 | id: "options-context-window", | ||
| 156 | title: "Context Window", | ||
| 157 | subtitle: flow.contextWindow, | ||
| 158 | subactions: (["200k", "1M"] as const).map((value) => ({ | ||
| 159 | id: `options:context-window:${value}`, | ||
| 160 | title: `${value}${value === "1M" ? " (default)" : ""}`, | ||
| 161 | state: flow.contextWindow === value ? ("on" as const) : undefined, | ||
| 162 | })), | ||
| 163 | }, | ||
| 164 | { | ||
| 165 | id: "options-runtime", | ||
| 166 | title: "Runtime", | ||
| 167 | subtitle: | ||
| 168 | flow.runtimeMode === "approval-required" | ||
| 169 | ? "Approve actions" | ||
| 170 | : flow.runtimeMode === "auto-accept-edits" | ||
| 171 | ? "Auto-accept edits" | ||
| 172 | : "Full access", | ||
| 173 | subactions: [ | ||
| 174 | { id: "options:runtime:approval-required", title: "Approve actions" }, | ||
| 175 | { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, | ||
| 176 | { id: "options:runtime:full-access", title: "Full access" }, | ||
| 177 | ].map((option) => { | ||
| 178 | const value = option.id.replace("options:runtime:", ""); | ||
| 179 | return { | ||
| 180 | id: option.id, | ||
| 181 | title: option.title, | ||
| 182 | state: flow.runtimeMode === value ? ("on" as const) : undefined, | ||
| 183 | }; | ||
| 184 | }), | ||
| 185 | }, | ||
| 186 | { | ||
| 187 | id: "options-interaction", | ||
| 188 | title: "Interaction", | ||
| 189 | subtitle: flow.interactionMode === "plan" ? "Plan" : "Default", | ||
| 190 | subactions: [ | ||
| 191 | { id: "options:interaction:default", title: "Default" }, | ||
| 192 | { id: "options:interaction:plan", title: "Plan" }, | ||
| 193 | ].map((option) => { | ||
| 194 | const value = option.id.replace("options:interaction:", ""); | ||
| 195 | return { | ||
| 196 | id: option.id, | ||
| 197 | title: option.title, | ||
| 198 | state: flow.interactionMode === value ? ("on" as const) : undefined, | ||
| 199 | }; | ||
| 200 | }), | ||
| 201 | }, | ||
| 202 | ], | ||
| 203 | [flow.contextWindow, flow.effort, flow.fastMode, flow.interactionMode, flow.runtimeMode], | ||
| 204 | ); | ||
| 205 | |||
| 206 | const workspaceMenuActions = useMemo(() => { | ||
| 207 | const branchActions = | ||
| 208 | flow.availableBranches.length === 0 | ||
| 209 | ? [ | ||
| 210 | { | ||
| 211 | id: "workspace:branch:none", | ||
| 212 | title: flow.branchesLoading ? "Loading branches…" : "No branches available", | ||
| 213 | attributes: { disabled: true }, | ||
| 214 | }, | ||
| 215 | ] | ||
| 216 | : flow.availableBranches.slice(0, 12).map((branch) => { | ||
| 217 | const badge = branchBadgeLabel({ | ||
| 218 | branch, | ||
| 219 | project: flow.selectedProject, | ||
| 220 | }); | ||
| 221 | |||
| 222 | return { | ||
| 223 | id: `workspace:branch:${branch.name}`, | ||
| 224 | title: branch.name, | ||
| 225 | subtitle: badge ? badge.toUpperCase() : undefined, | ||
| 226 | state: flow.selectedBranchName === branch.name ? ("on" as const) : undefined, | ||
| 227 | }; | ||
| 228 | }); | ||
| 229 | |||
| 230 | return [ | ||
| 231 | { | ||
| 232 | id: "workspace:mode", | ||
| 233 | title: "Mode", | ||
| 234 | subtitle: flow.workspaceMode === "local" ? "Local" : "Worktree", | ||
| 235 | subactions: (["local", "worktree"] as const).map((value) => ({ | ||
| 236 | id: `workspace:mode:${value}`, | ||
| 237 | title: value === "local" ? "Local" : "Worktree", | ||
| 238 | state: flow.workspaceMode === value ? ("on" as const) : undefined, | ||
| 239 | })), | ||
| 240 | }, | ||
| 241 | { | ||
| 242 | id: "workspace:branch", | ||
| 243 | title: "Branch", | ||
| 244 | subtitle: flow.selectedBranchName ?? "Choose branch", | ||
| 245 | subactions: branchActions, | ||
| 246 | }, | ||
| 247 | ]; | ||
| 248 | }, [ | ||
| 249 | flow.availableBranches, | ||
| 250 | flow.branchesLoading, | ||
| 251 | flow.selectedBranchName, | ||
| 252 | flow.selectedProject, | ||
| 253 | flow.workspaceMode, | ||
| 254 | ]); | ||
| 255 | |||
| 256 | function handleModelMenuAction(event: string) { | ||
| 257 | if (!event.startsWith("model:")) { | ||
| 258 | return; | ||
| 259 | } | ||
| 260 | // Defer state update so the native menu dismiss animation completes | ||
| 261 | // before re-rendering the menu actions (prevents submenu jump). | ||
| 262 | setTimeout(() => { | ||
| 263 | flow.setSelectedModelKey(event.slice("model:".length)); | ||
| 264 | }, 150); | ||
| 265 | } | ||
| 266 | |||
| 267 | function handleEnvironmentMenuAction(event: string) { | ||
| 268 | if (!event.startsWith("environment:")) { | ||
| 269 | return; | ||
| 270 | } | ||
| 271 | flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); | ||
| 272 | } | ||
| 273 | |||
| 274 | function handleOptionsMenuAction(event: string) { | ||
| 275 | if (event.startsWith("options:effort:")) { | ||
| 276 | flow.setEffort(event.slice("options:effort:".length) as typeof flow.effort); | ||
| 277 | return; | ||
| 278 | } | ||
| 279 | if (event.startsWith("options:fast-mode:")) { | ||
| 280 | flow.setFastMode(event.endsWith(":on")); | ||
| 281 | return; | ||
| 282 | } | ||
| 283 | if (event.startsWith("options:context-window:")) { | ||
| 284 | flow.setContextWindow(event.slice("options:context-window:".length)); | ||
| 285 | return; | ||
| 286 | } | ||
| 287 | if (event.startsWith("options:runtime:")) { | ||
| 288 | flow.setRuntimeMode( | ||
| 289 | event.slice("options:runtime:".length) as Parameters<typeof flow.setRuntimeMode>[0], | ||
| 290 | ); | ||
| 291 | return; | ||
| 292 | } | ||
| 293 | if (event.startsWith("options:interaction:")) { | ||
| 294 | flow.setInteractionMode( | ||
| 295 | event.slice("options:interaction:".length) as Parameters<typeof flow.setInteractionMode>[0], | ||
| 296 | ); | ||
| 297 | } | ||
| 298 | } | ||
| 299 | |||
| 300 | function handleWorkspaceMenuAction(event: string) { | ||
| 301 | if (event.startsWith("workspace:mode:")) { | ||
| 302 | flow.setWorkspaceMode( | ||
| 303 | event.slice("workspace:mode:".length) as Parameters<typeof flow.setWorkspaceMode>[0], | ||
| 304 | ); | ||
| 305 | return; | ||
| 306 | } | ||
| 307 | if (event.startsWith("workspace:branch:")) { | ||
| 308 | const branchName = event.slice("workspace:branch:".length); | ||
| 309 | const branch = flow.availableBranches.find((candidate) => candidate.name === branchName); | ||
| 310 | if (branch) { | ||
| 311 | flow.selectBranch(branch); | ||
| 312 | } | ||
| 313 | } | ||
| 314 | } | ||
| 315 | |||
| 316 | async function handlePickImages(): Promise<void> { | ||
| 317 | const result = await pickComposerImages({ existingCount: flow.attachments.length }); | ||
| 318 | if (result.images.length > 0) { | ||
| 319 | flow.appendAttachments(result.images); | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | const handleNativePasteImages = useCallback( | ||
| 324 | async (uris: ReadonlyArray<string>) => { | ||
| 325 | try { | ||
| 326 | const images = await convertPastedImagesToAttachments({ | ||
| 327 | uris, | ||
| 328 | existingCount: flow.attachments.length, | ||
| 329 | }); | ||
| 330 | if (images.length > 0) { | ||
| 331 | flow.appendAttachments(images); | ||
| 332 | } | ||
| 333 | } catch (error) { | ||
| 334 | console.error("[native paste] error converting images", error); | ||
| 335 | } | ||
| 336 | }, | ||
| 337 | [flow], | ||
| 338 | ); | ||
| 339 | |||
| 340 | const handleNativePaste = useNativePaste((uris) => { | ||
| 341 | void handleNativePasteImages(uris); | ||
| 342 | }); | ||
| 343 | |||
| 344 | async function handleStart(): Promise<void> { | ||
| 345 | if ( | ||
| 346 | !flow.selectedProject || | ||
| 347 | !flow.selectedModel || | ||
| 348 | flow.prompt.trim().length === 0 || | ||
| 349 | flow.submitting || | ||
| 350 | (flow.workspaceMode === "worktree" && !flow.selectedBranchName) | ||
| 351 | ) { | ||
| 352 | return; | ||
| 353 | } | ||
| 354 | |||
| 355 | flow.setSubmitting(true); | ||
| 356 | try { | ||
| 357 | const modelWithOptions: ModelSelection = | ||
| 358 | flow.selectedModelOption?.providerDriver === "claudeAgent" | ||
| 359 | ? withModelSelectionOption( | ||
| 360 | withModelSelectionOption( | ||
| 361 | withModelSelectionOption(flow.selectedModel, "effort", flow.effort), | ||
| 362 | "fastMode", | ||
| 363 | flow.fastMode || undefined, | ||
| 364 | ), | ||
| 365 | "contextWindow", | ||
| 366 | flow.contextWindow, | ||
| 367 | ) | ||
| 368 | : flow.selectedModelOption?.providerDriver === "codex" | ||
| 369 | ? withModelSelectionOption(flow.selectedModel, "fastMode", flow.fastMode || undefined) | ||
| 370 | : flow.selectedModel; | ||
| 371 | |||
| 372 | const createdThread = await onCreateThreadWithOptions({ | ||
| 373 | project: flow.selectedProject, | ||
| 374 | modelSelection: modelWithOptions, | ||
| 375 | envMode: flow.workspaceMode, | ||
| 376 | branch: flow.selectedBranchName, | ||
| 377 | worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath, | ||
| 378 | runtimeMode: flow.runtimeMode, | ||
| 379 | interactionMode: flow.interactionMode, | ||
| 380 | initialMessageText: flow.prompt.trim(), | ||
| 381 | initialAttachments: flow.attachments, | ||
| 382 | }); | ||
| 383 | |||
| 384 | if (createdThread) { | ||
| 385 | router.replace(buildThreadRoutePath(createdThread)); | ||
| 386 | } | ||
| 387 | } finally { | ||
| 388 | flow.setSubmitting(false); | ||
| 389 | } | ||
| 390 | } | ||
| 391 | |||
| 392 | if (!selectedProject) { | ||
| 393 | return ( | ||
| 394 | <View className="flex-1 bg-sheet"> | ||
| 395 | <NewTaskSheetHeader title="Loading task" /> | ||
| 396 | </View> | ||
| 397 | ); | ||
| 398 | } | ||
| 399 | |||
| 400 | return ( | ||
| 401 | <View className="flex-1 bg-sheet"> | ||
| 402 | <NewTaskSheetHeader | ||
| 403 | title={selectedProject.title} | ||
| 404 | control={ | ||
| 405 | flow.logicalProjects.length > 1 | ||
| 406 | ? { icon: "chevron.left", onPress: () => router.back() } | ||
| 407 | : undefined | ||
| 408 | } | ||
| 409 | /> | ||
| 410 | |||
| 411 | <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}> | ||
| 412 | <TextInputWrapper | ||
| 413 | onPaste={(payload) => void handleNativePaste(payload)} | ||
| 414 | style={{ flex: 1 }} | ||
| 415 | > | ||
| 416 | <TextInput | ||
| 417 | multiline | ||
| 418 | value={flow.prompt} | ||
| 419 | onChangeText={flow.setPrompt} | ||
| 420 | placeholder={`Describe a coding task in ${selectedProject.title}`} | ||
| 421 | textAlignVertical="top" | ||
| 422 | className="h-full flex-1 border-0 bg-transparent text-[18px] leading-[28px]" | ||
| 423 | style={{ flex: 1 }} | ||
| 424 | /> | ||
| 425 | </TextInputWrapper> | ||
| 426 | </View> | ||
| 427 | |||
| 428 | <KeyboardStickyView> | ||
| 429 | <View | ||
| 430 | style={{ | ||
| 431 | borderTopWidth: 1, | ||
| 432 | borderTopColor: borderColor, | ||
| 433 | paddingBottom: controlsBottomPadding, | ||
| 434 | }} | ||
| 435 | > | ||
| 436 | {flow.attachments.length > 0 ? ( | ||
| 437 | <View style={{ paddingHorizontal: 16, paddingTop: 12 }}> | ||
| 438 | <ComposerAttachmentStrip | ||
| 439 | attachments={flow.attachments} | ||
| 440 | onRemove={flow.removeAttachment} | ||
| 441 | imageSize={88} | ||
| 442 | imageBorderRadius={20} | ||
| 443 | /> | ||
| 444 | </View> | ||
| 445 | ) : null} | ||
| 446 | <View className="flex-row items-center justify-between gap-2 px-4 pt-2"> | ||
| 447 | <ControlPill icon="plus" onPress={() => void handlePickImages()} /> | ||
| 448 | <ControlPillMenu | ||
| 449 | actions={modelMenuActions} | ||
| 450 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} | ||
| 451 | > | ||
| 452 | <ControlPill | ||
| 453 | iconNode={ | ||
| 454 | <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} /> | ||
| 455 | } | ||
| 456 | /> | ||
| 457 | </ControlPillMenu> | ||
| 458 | <ControlPillMenu | ||
| 459 | actions={optionsMenuActions} | ||
| 460 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} | ||
| 461 | > | ||
| 462 | <ControlPill icon="slider.horizontal.3" /> | ||
| 463 | </ControlPillMenu> | ||
| 464 | <ControlPillMenu | ||
| 465 | actions={environmentMenuActions} | ||
| 466 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} | ||
| 467 | > | ||
| 468 | <ControlPill icon="desktopcomputer" /> | ||
| 469 | </ControlPillMenu> | ||
| 470 | <ControlPillMenu | ||
| 471 | actions={workspaceMenuActions} | ||
| 472 | onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)} | ||
| 473 | > | ||
| 474 | <ControlPill icon="point.topleft.down.curvedto.point.bottomright.up" /> | ||
| 475 | </ControlPillMenu> | ||
| 476 | <ControlPill | ||
| 477 | icon="arrow.up" | ||
| 478 | label={flow.submitting ? "Starting" : "Start"} | ||
| 479 | onPress={() => void handleStart()} | ||
| 480 | variant="primary" | ||
| 481 | disabled={ | ||
| 482 | !flow.selectedProject || | ||
| 483 | !flow.selectedModel || | ||
| 484 | flow.prompt.trim().length === 0 || | ||
| 485 | flow.submitting || | ||
| 486 | (flow.workspaceMode === "worktree" && !flow.selectedBranchName) | ||
| 487 | } | ||
| 488 | /> | ||
| 489 | </View> | ||
| 490 | </View> | ||
| 491 | </KeyboardStickyView> | ||
| 492 | </View> | ||
| 493 | ); | ||
| 494 | } | ||
apps/mobile/src/features/threads/NewTaskSheetHeader.tsx created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { Pressable, View } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "../../components/AppText"; | ||
| 5 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 6 | |||
| 7 | export function NewTaskSheetHeader(props: { | ||
| 8 | readonly eyebrow?: string; | ||
| 9 | readonly title: string; | ||
| 10 | readonly control?: { | ||
| 11 | readonly icon: "chevron.left" | "xmark"; | ||
| 12 | readonly onPress: () => void; | ||
| 13 | }; | ||
| 14 | }) { | ||
| 15 | const iconColor = useThemeColor("--color-icon"); | ||
| 16 | |||
| 17 | return ( | ||
| 18 | <> | ||
| 19 | <View style={{ minHeight: 16, paddingTop: 8 }} /> | ||
| 20 | <View className="items-center gap-1 px-5 pb-3 pt-4"> | ||
| 21 | {props.control ? ( | ||
| 22 | <Pressable | ||
| 23 | className="absolute left-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle" | ||
| 24 | style={{ zIndex: 1 }} | ||
| 25 | onPress={props.control.onPress} | ||
| 26 | > | ||
| 27 | <SymbolView | ||
| 28 | name={props.control.icon} | ||
| 29 | size={16} | ||
| 30 | tintColor={iconColor} | ||
| 31 | type="monochrome" | ||
| 32 | weight="medium" | ||
| 33 | /> | ||
| 34 | </Pressable> | ||
| 35 | ) : null} | ||
| 36 | <Text | ||
| 37 | className="text-[12px] font-t3-bold uppercase text-foreground-muted" | ||
| 38 | style={{ letterSpacing: 1 }} | ||
| 39 | > | ||
| 40 | {props.eyebrow ?? "New task"} | ||
| 41 | </Text> | ||
| 42 | <Text className="text-[28px] font-t3-bold">{props.title}</Text> | ||
| 43 | </View> | ||
| 44 | </> | ||
| 45 | ); | ||
| 46 | } | ||
apps/mobile/src/features/threads/PendingApprovalCard.tsx created+57| ... | @@ -0,0 +1,57 @@ | ||
| 1 | import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts"; | ||
| 2 | import { Pressable, View } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "../../components/AppText"; | ||
| 5 | import type { PendingApproval } from "../../lib/threadActivity"; | ||
| 6 | |||
| 7 | export interface PendingApprovalCardProps { | ||
| 8 | readonly approval: PendingApproval; | ||
| 9 | readonly respondingApprovalId: ApprovalRequestId | null; | ||
| 10 | readonly onRespond: ( | ||
| 11 | requestId: ApprovalRequestId, | ||
| 12 | decision: ProviderApprovalDecision, | ||
| 13 | ) => Promise<void>; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function PendingApprovalCard(props: PendingApprovalCardProps) { | ||
| 17 | return ( | ||
| 18 | <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80"> | ||
| 19 | <Text className="font-t3-bold text-[11px] uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300"> | ||
| 20 | Approval needed | ||
| 21 | </Text> | ||
| 22 | <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50"> | ||
| 23 | {props.approval.requestKind} | ||
| 24 | </Text> | ||
| 25 | {props.approval.detail ? ( | ||
| 26 | <Text className="font-sans text-sm leading-5 text-neutral-600 dark:text-neutral-400"> | ||
| 27 | {props.approval.detail} | ||
| 28 | </Text> | ||
| 29 | ) : null} | ||
| 30 | <View className="flex-row flex-wrap gap-2.5"> | ||
| 31 | <Pressable | ||
| 32 | className="items-center justify-center rounded-[14px] bg-blue-500 px-3.5 py-3" | ||
| 33 | disabled={props.respondingApprovalId === props.approval.requestId} | ||
| 34 | onPress={() => void props.onRespond(props.approval.requestId, "accept")} | ||
| 35 | > | ||
| 36 | <Text className="font-t3-extrabold text-sm text-white">Allow once</Text> | ||
| 37 | </Pressable> | ||
| 38 | <Pressable | ||
| 39 | className="items-center justify-center rounded-[14px] bg-neutral-200 px-3.5 py-3 dark:bg-neutral-800" | ||
| 40 | disabled={props.respondingApprovalId === props.approval.requestId} | ||
| 41 | onPress={() => void props.onRespond(props.approval.requestId, "acceptForSession")} | ||
| 42 | > | ||
| 43 | <Text className="font-t3-bold text-sm text-neutral-950 dark:text-neutral-50"> | ||
| 44 | Allow session | ||
| 45 | </Text> | ||
| 46 | </Pressable> | ||
| 47 | <Pressable | ||
| 48 | className="items-center justify-center rounded-[14px] bg-rose-100 px-3.5 py-3 dark:bg-rose-500/18" | ||
| 49 | disabled={props.respondingApprovalId === props.approval.requestId} | ||
| 50 | onPress={() => void props.onRespond(props.approval.requestId, "decline")} | ||
| 51 | > | ||
| 52 | <Text className="font-t3-bold text-sm text-rose-700 dark:text-rose-300">Decline</Text> | ||
| 53 | </Pressable> | ||
| 54 | </View> | ||
| 55 | </View> | ||
| 56 | ); | ||
| 57 | } | ||
apps/mobile/src/features/threads/PendingUserInputCard.tsx created+105| ... | @@ -0,0 +1,105 @@ | ||
| 1 | import type { ApprovalRequestId } from "@t3tools/contracts"; | ||
| 2 | import { Pressable, View } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 5 | import { cn } from "../../lib/cn"; | ||
| 6 | import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; | ||
| 7 | |||
| 8 | export interface PendingUserInputCardProps { | ||
| 9 | readonly pendingUserInput: PendingUserInput; | ||
| 10 | readonly drafts: Record<string, PendingUserInputDraftAnswer>; | ||
| 11 | readonly answers: Record<string, string> | null; | ||
| 12 | readonly respondingUserInputId: ApprovalRequestId | null; | ||
| 13 | readonly onSelectOption: ( | ||
| 14 | requestId: ApprovalRequestId, | ||
| 15 | questionId: string, | ||
| 16 | label: string, | ||
| 17 | ) => void; | ||
| 18 | readonly onChangeCustomAnswer: ( | ||
| 19 | requestId: ApprovalRequestId, | ||
| 20 | questionId: string, | ||
| 21 | customAnswer: string, | ||
| 22 | ) => void; | ||
| 23 | readonly onSubmit: () => Promise<void>; | ||
| 24 | } | ||
| 25 | |||
| 26 | export function PendingUserInputCard(props: PendingUserInputCardProps) { | ||
| 27 | return ( | ||
| 28 | <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100/80 p-4 dark:border-white/6 dark:bg-neutral-900/80"> | ||
| 29 | <Text className="font-t3-bold text-[11px] uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300"> | ||
| 30 | User input needed | ||
| 31 | </Text> | ||
| 32 | <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50"> | ||
| 33 | Fill in the pending answers | ||
| 34 | </Text> | ||
| 35 | {props.pendingUserInput.questions.map((question) => { | ||
| 36 | const draft = props.drafts[question.id]; | ||
| 37 | return ( | ||
| 38 | <View key={question.id} className="gap-2 pt-1"> | ||
| 39 | <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500"> | ||
| 40 | {question.header} | ||
| 41 | </Text> | ||
| 42 | <Text className="font-sans text-[15px] leading-[21px] text-neutral-950 dark:text-neutral-50"> | ||
| 43 | {question.question} | ||
| 44 | </Text> | ||
| 45 | <View className="flex-row flex-wrap gap-2.5"> | ||
| 46 | {question.options.map((option) => { | ||
| 47 | const selected = | ||
| 48 | draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; | ||
| 49 | return ( | ||
| 50 | <Pressable | ||
| 51 | key={option.label} | ||
| 52 | className={cn( | ||
| 53 | "rounded-full border px-3 py-2.5 ", | ||
| 54 | selected | ||
| 55 | ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14" | ||
| 56 | : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70", | ||
| 57 | )} | ||
| 58 | onPress={() => | ||
| 59 | props.onSelectOption( | ||
| 60 | props.pendingUserInput.requestId, | ||
| 61 | question.id, | ||
| 62 | option.label, | ||
| 63 | ) | ||
| 64 | } | ||
| 65 | > | ||
| 66 | <Text | ||
| 67 | className={cn( | ||
| 68 | "font-t3-bold text-[13px]", | ||
| 69 | selected | ||
| 70 | ? "text-sky-700 dark:text-sky-300" | ||
| 71 | : "text-neutral-600 dark:text-neutral-300", | ||
| 72 | )} | ||
| 73 | > | ||
| 74 | {option.label} | ||
| 75 | </Text> | ||
| 76 | </Pressable> | ||
| 77 | ); | ||
| 78 | })} | ||
| 79 | </View> | ||
| 80 | <TextInput | ||
| 81 | value={draft?.customAnswer ?? ""} | ||
| 82 | onChangeText={(value) => | ||
| 83 | props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) | ||
| 84 | } | ||
| 85 | placeholder="Or type a custom answer" | ||
| 86 | className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-[15px] text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" | ||
| 87 | /> | ||
| 88 | </View> | ||
| 89 | ); | ||
| 90 | })} | ||
| 91 | <Pressable | ||
| 92 | className={cn( | ||
| 93 | "items-center justify-center rounded-2xl px-4 py-3.5", | ||
| 94 | props.answers ? "bg-blue-500" : "bg-neutral-200 dark:bg-neutral-700/60", | ||
| 95 | )} | ||
| 96 | disabled={ | ||
| 97 | props.answers === null || props.respondingUserInputId === props.pendingUserInput.requestId | ||
| 98 | } | ||
| 99 | onPress={() => void props.onSubmit()} | ||
| 100 | > | ||
| 101 | <Text className="font-t3-extrabold text-sm text-white">Submit answers</Text> | ||
| 102 | </Pressable> | ||
| 103 | </View> | ||
| 104 | ); | ||
| 105 | } | ||
apps/mobile/src/features/threads/ThreadComposer.tsx created+821| ... | @@ -0,0 +1,821 @@ | ||
| 1 | import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; | ||
| 2 | import type { | ||
| 3 | EnvironmentId, | ||
| 4 | ModelSelection, | ||
| 5 | OrchestrationThread, | ||
| 6 | ProviderInteractionMode, | ||
| 7 | RuntimeMode, | ||
| 8 | ServerConfig as T3ServerConfig, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { | ||
| 11 | detectComposerTrigger, | ||
| 12 | replaceTextRange, | ||
| 13 | type ComposerTrigger, | ||
| 14 | } from "@t3tools/shared/composerTrigger"; | ||
| 15 | import { TextInputWrapper } from "expo-paste-input"; | ||
| 16 | import type { ReactNode } from "react"; | ||
| 17 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 18 | import { | ||
| 19 | Image, | ||
| 20 | Pressable, | ||
| 21 | TextInput as RNTextInput, | ||
| 22 | useColorScheme, | ||
| 23 | View, | ||
| 24 | type NativeSyntheticEvent, | ||
| 25 | type TextInputSelectionChangeEventData, | ||
| 26 | type ViewStyle, | ||
| 27 | } from "react-native"; | ||
| 28 | import ImageViewing from "react-native-image-viewing"; | ||
| 29 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 30 | |||
| 31 | import { AppText as Text } from "../../components/AppText"; | ||
| 32 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | ||
| 33 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | ||
| 34 | import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| 35 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | ||
| 36 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | ||
| 37 | import type { RemoteClientConnectionState } from "../../lib/connection"; | ||
| 38 | import { useNativePaste } from "../../lib/useNativePaste"; | ||
| 39 | import { | ||
| 40 | insertRankedSearchResult, | ||
| 41 | normalizeSearchQuery, | ||
| 42 | scoreQueryMatch, | ||
| 43 | } from "@t3tools/shared/searchRanking"; | ||
| 44 | import { | ||
| 45 | getModelSelectionBooleanOptionValue, | ||
| 46 | getModelSelectionStringOptionValue, | ||
| 47 | } from "@t3tools/shared/model"; | ||
| 48 | import { useComposerPathSearch } from "../../state/use-composer-path-search"; | ||
| 49 | import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; | ||
| 50 | import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; | ||
| 51 | |||
| 52 | /** | ||
| 53 | * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). | ||
| 54 | * Exported so the parent can compute feed overlap / content insets. | ||
| 55 | */ | ||
| 56 | export const COMPOSER_COLLAPSED_CHROME = 68; | ||
| 57 | |||
| 58 | /** | ||
| 59 | * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). | ||
| 60 | * Used by the parent to compute the larger feed bottom inset when the composer is focused. | ||
| 61 | */ | ||
| 62 | export const COMPOSER_EXPANDED_CHROME = 174; | ||
| 63 | |||
| 64 | /** | ||
| 65 | * Height of the expanded-only toolbar below the text surface. | ||
| 66 | * Used by the feed inset because KeyboardAvoidingLegendList only accounts for | ||
| 67 | * keyboard height; the floating toolbar remains an additional overlay. | ||
| 68 | */ | ||
| 69 | export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 54; | ||
| 70 | |||
| 71 | export interface ThreadComposerProps { | ||
| 72 | readonly draftMessage: string; | ||
| 73 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 74 | readonly placeholder: string; | ||
| 75 | readonly bottomInset?: number; | ||
| 76 | readonly connectionState: RemoteClientConnectionState; | ||
| 77 | readonly selectedThread: OrchestrationThread; | ||
| 78 | readonly serverConfig: T3ServerConfig | null; | ||
| 79 | readonly queueCount: number; | ||
| 80 | readonly activeThreadBusy: boolean; | ||
| 81 | readonly environmentId: EnvironmentId; | ||
| 82 | readonly projectCwd: string | null; | ||
| 83 | readonly onChangeDraftMessage: (value: string) => void; | ||
| 84 | readonly onPickDraftImages: () => Promise<void>; | ||
| 85 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | ||
| 86 | readonly onRemoveDraftImage: (imageId: string) => void; | ||
| 87 | readonly onRefresh: () => Promise<void>; | ||
| 88 | readonly onStopThread: () => Promise<void>; | ||
| 89 | readonly onSendMessage: () => void; | ||
| 90 | readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise<void>; | ||
| 91 | readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => Promise<void>; | ||
| 92 | readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => Promise<void>; | ||
| 93 | readonly onExpandedChange?: (expanded: boolean) => void; | ||
| 94 | } | ||
| 95 | |||
| 96 | /** | ||
| 97 | * The pill / card container — renders as LiquidGlassView on supported | ||
| 98 | * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. | ||
| 99 | */ | ||
| 100 | function ComposerSurface(props: { | ||
| 101 | readonly children: ReactNode; | ||
| 102 | readonly style: ViewStyle; | ||
| 103 | readonly isDarkMode: boolean; | ||
| 104 | }) { | ||
| 105 | if (isLiquidGlassSupported) { | ||
| 106 | return ( | ||
| 107 | <LiquidGlassView | ||
| 108 | effect="clear" | ||
| 109 | interactive | ||
| 110 | tintColor={props.isDarkMode ? "rgba(44,44,46,0.5)" : "rgba(255,255,255,0.45)"} | ||
| 111 | colorScheme={props.isDarkMode ? "dark" : "light"} | ||
| 112 | style={props.style} | ||
| 113 | > | ||
| 114 | {props.children} | ||
| 115 | </LiquidGlassView> | ||
| 116 | ); | ||
| 117 | } | ||
| 118 | |||
| 119 | return ( | ||
| 120 | <View | ||
| 121 | style={[ | ||
| 122 | props.style, | ||
| 123 | { | ||
| 124 | backgroundColor: props.isDarkMode ? "rgba(44,44,46,0.96)" : "rgba(255,255,255,0.96)", | ||
| 125 | borderWidth: 1, | ||
| 126 | borderColor: props.isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", | ||
| 127 | }, | ||
| 128 | ]} | ||
| 129 | > | ||
| 130 | {props.children} | ||
| 131 | </View> | ||
| 132 | ); | ||
| 133 | } | ||
| 134 | |||
| 135 | function withModelSelectionOption( | ||
| 136 | selection: ModelSelection, | ||
| 137 | id: string, | ||
| 138 | value: string | boolean | undefined, | ||
| 139 | ): ModelSelection { | ||
| 140 | const options = (selection.options ?? []).filter((option) => option.id !== id); | ||
| 141 | return { | ||
| 142 | ...selection, | ||
| 143 | options: value === undefined ? options : [...options, { id, value }], | ||
| 144 | }; | ||
| 145 | } | ||
| 146 | |||
| 147 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { | ||
| 148 | const isDarkMode = useColorScheme() === "dark"; | ||
| 149 | const themePlaceholderColor = useThemeColor("--color-placeholder"); | ||
| 150 | const placeholderColor = isDarkMode ? "#a1a1aa" : themePlaceholderColor; | ||
| 151 | const foregroundColor = useThemeColor("--color-foreground"); | ||
| 152 | const inputRef = useRef<RNTextInput>(null); | ||
| 153 | const [isFocused, setIsFocused] = useState(false); | ||
| 154 | const wasExpandedBeforePreviewRef = useRef(false); | ||
| 155 | const { onExpandedChange } = props; | ||
| 156 | |||
| 157 | const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); | ||
| 158 | const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; | ||
| 159 | const isExpanded = isFocused; | ||
| 160 | const canSend = props.connectionState === "ready" && hasContent; | ||
| 161 | |||
| 162 | const onPressImage = useCallback( | ||
| 163 | (uri: string) => { | ||
| 164 | wasExpandedBeforePreviewRef.current = isFocused; | ||
| 165 | setPreviewImageUri(uri); | ||
| 166 | }, | ||
| 167 | [isFocused], | ||
| 168 | ); | ||
| 169 | |||
| 170 | const closePreview = useCallback(() => { | ||
| 171 | setPreviewImageUri(null); | ||
| 172 | if (wasExpandedBeforePreviewRef.current) { | ||
| 173 | setTimeout(() => inputRef.current?.focus(), 100); | ||
| 174 | } | ||
| 175 | }, []); | ||
| 176 | |||
| 177 | useEffect(() => { | ||
| 178 | onExpandedChange?.(isExpanded); | ||
| 179 | }, [isExpanded, onExpandedChange]); | ||
| 180 | const showStopAction = | ||
| 181 | props.selectedThread.session?.status === "running" || | ||
| 182 | props.selectedThread.session?.status === "starting" || | ||
| 183 | props.queueCount > 0; | ||
| 184 | |||
| 185 | const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send"; | ||
| 186 | const modelProvider = props.selectedThread.modelSelection?.instanceId ?? null; | ||
| 187 | const currentModelSelection = props.selectedThread.modelSelection; | ||
| 188 | const currentRuntimeMode = props.selectedThread.runtimeMode; | ||
| 189 | const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; | ||
| 190 | const selectedProviderStatus = useMemo(() => { | ||
| 191 | if (!props.serverConfig) return null; | ||
| 192 | return ( | ||
| 193 | props.serverConfig.providers.find( | ||
| 194 | (p) => p.instanceId === props.selectedThread.modelSelection.instanceId, | ||
| 195 | ) ?? null | ||
| 196 | ); | ||
| 197 | }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); | ||
| 198 | |||
| 199 | // Extract current model options (effort, fastMode, contextWindow) | ||
| 200 | const selectedProviderDriver = selectedProviderStatus?.driver ?? null; | ||
| 201 | const currentEffort = | ||
| 202 | selectedProviderDriver === "claudeAgent" | ||
| 203 | ? (getModelSelectionStringOptionValue(currentModelSelection, "effort") ?? "high") | ||
| 204 | : "high"; | ||
| 205 | const currentFastMode = | ||
| 206 | getModelSelectionBooleanOptionValue(currentModelSelection, "fastMode") ?? false; | ||
| 207 | const currentContextWindow = | ||
| 208 | selectedProviderDriver === "claudeAgent" | ||
| 209 | ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") | ||
| 210 | : "1M"; | ||
| 211 | |||
| 212 | const handleNativePaste = useNativePaste((uris) => { | ||
| 213 | void props.onNativePasteImages(uris); | ||
| 214 | }); | ||
| 215 | |||
| 216 | // ── Trigger detection ──────────────────────────────────── | ||
| 217 | const [cursorPosition, setCursorPosition] = useState(0); | ||
| 218 | |||
| 219 | const handleSelectionChange = useCallback( | ||
| 220 | (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { | ||
| 221 | const { start } = event.nativeEvent.selection; | ||
| 222 | setCursorPosition(start); | ||
| 223 | }, | ||
| 224 | [], | ||
| 225 | ); | ||
| 226 | |||
| 227 | const composerTrigger = useMemo<ComposerTrigger | null>( | ||
| 228 | () => detectComposerTrigger(props.draftMessage, cursorPosition), | ||
| 229 | [cursorPosition, props.draftMessage], | ||
| 230 | ); | ||
| 231 | const pathSearch = useComposerPathSearch({ | ||
| 232 | environmentId: props.environmentId, | ||
| 233 | cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, | ||
| 234 | query: composerTrigger?.kind === "path" ? composerTrigger.query : null, | ||
| 235 | }); | ||
| 236 | |||
| 237 | const composerMenuItems: ComposerCommandItem[] = useMemo(() => { | ||
| 238 | if (!composerTrigger) return []; | ||
| 239 | |||
| 240 | if (composerTrigger.kind === "slash-command") { | ||
| 241 | const q = composerTrigger.query.toLowerCase(); | ||
| 242 | const allBuiltIn = [ | ||
| 243 | { | ||
| 244 | id: "cmd:model", | ||
| 245 | type: "slash-command" as const, | ||
| 246 | command: "model", | ||
| 247 | label: "/model", | ||
| 248 | description: "Switch model", | ||
| 249 | }, | ||
| 250 | { | ||
| 251 | id: "cmd:plan", | ||
| 252 | type: "slash-command" as const, | ||
| 253 | command: "plan", | ||
| 254 | label: "/plan", | ||
| 255 | description: "Switch to plan mode", | ||
| 256 | }, | ||
| 257 | { | ||
| 258 | id: "cmd:default", | ||
| 259 | type: "slash-command" as const, | ||
| 260 | command: "default", | ||
| 261 | label: "/default", | ||
| 262 | description: "Switch to default mode", | ||
| 263 | }, | ||
| 264 | ]; | ||
| 265 | const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); | ||
| 266 | |||
| 267 | const providerCommands: ComposerCommandItem[] = []; | ||
| 268 | for (const cmd of selectedProviderStatus?.slashCommands ?? []) { | ||
| 269 | if (!cmd.name.toLowerCase().includes(q)) continue; | ||
| 270 | providerCommands.push({ | ||
| 271 | id: `pcmd:${cmd.name}`, | ||
| 272 | type: "provider-slash-command" as const, | ||
| 273 | command: cmd, | ||
| 274 | label: `/${cmd.name}`, | ||
| 275 | description: cmd.description ?? "", | ||
| 276 | }); | ||
| 277 | } | ||
| 278 | |||
| 279 | return [...builtIn, ...providerCommands]; | ||
| 280 | } | ||
| 281 | |||
| 282 | if (composerTrigger.kind === "skill") { | ||
| 283 | const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); | ||
| 284 | const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { | ||
| 285 | trimLeadingPattern: /^\$+/, | ||
| 286 | }); | ||
| 287 | |||
| 288 | if (!normalizedQuery) { | ||
| 289 | return enabledSkills.slice(0, 20).map((skill) => ({ | ||
| 290 | id: `skill:${skill.name}`, | ||
| 291 | type: "skill" as const, | ||
| 292 | skill, | ||
| 293 | label: skill.displayName ?? skill.name, | ||
| 294 | description: skill.shortDescription ?? skill.description ?? "", | ||
| 295 | })); | ||
| 296 | } | ||
| 297 | |||
| 298 | const ranked: Array<{ | ||
| 299 | item: (typeof enabledSkills)[number]; | ||
| 300 | score: number; | ||
| 301 | tieBreaker: string; | ||
| 302 | }> = []; | ||
| 303 | for (const skill of enabledSkills) { | ||
| 304 | const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); | ||
| 305 | const scores = [ | ||
| 306 | scoreQueryMatch({ | ||
| 307 | value: skill.name.toLowerCase(), | ||
| 308 | query: normalizedQuery, | ||
| 309 | exactBase: 0, | ||
| 310 | prefixBase: 2, | ||
| 311 | boundaryBase: 4, | ||
| 312 | includesBase: 6, | ||
| 313 | fuzzyBase: 100, | ||
| 314 | boundaryMarkers: ["-", "_", "/"], | ||
| 315 | }), | ||
| 316 | scoreQueryMatch({ | ||
| 317 | value: displayLabel, | ||
| 318 | query: normalizedQuery, | ||
| 319 | exactBase: 1, | ||
| 320 | prefixBase: 3, | ||
| 321 | boundaryBase: 5, | ||
| 322 | includesBase: 7, | ||
| 323 | fuzzyBase: 110, | ||
| 324 | }), | ||
| 325 | scoreQueryMatch({ | ||
| 326 | value: skill.shortDescription?.toLowerCase() ?? "", | ||
| 327 | query: normalizedQuery, | ||
| 328 | exactBase: 20, | ||
| 329 | prefixBase: 22, | ||
| 330 | boundaryBase: 24, | ||
| 331 | includesBase: 26, | ||
| 332 | }), | ||
| 333 | scoreQueryMatch({ | ||
| 334 | value: skill.description?.toLowerCase() ?? "", | ||
| 335 | query: normalizedQuery, | ||
| 336 | exactBase: 30, | ||
| 337 | prefixBase: 32, | ||
| 338 | boundaryBase: 34, | ||
| 339 | includesBase: 36, | ||
| 340 | }), | ||
| 341 | ].filter((s): s is number => s !== null); | ||
| 342 | |||
| 343 | if (scores.length > 0) { | ||
| 344 | insertRankedSearchResult( | ||
| 345 | ranked, | ||
| 346 | { | ||
| 347 | item: skill, | ||
| 348 | score: Math.min(...scores), | ||
| 349 | tieBreaker: `${displayLabel}\u0000${skill.name}`, | ||
| 350 | }, | ||
| 351 | 20, | ||
| 352 | ); | ||
| 353 | } | ||
| 354 | } | ||
| 355 | |||
| 356 | return ranked.map(({ item: skill }) => ({ | ||
| 357 | id: `skill:${skill.name}`, | ||
| 358 | type: "skill" as const, | ||
| 359 | skill, | ||
| 360 | label: skill.displayName ?? skill.name, | ||
| 361 | description: skill.shortDescription ?? skill.description ?? "", | ||
| 362 | })); | ||
| 363 | } | ||
| 364 | |||
| 365 | if (composerTrigger.kind === "path") { | ||
| 366 | return pathSearch.entries.map((entry) => { | ||
| 367 | const parts = entry.path.split("/"); | ||
| 368 | return { | ||
| 369 | id: `path:${entry.path}`, | ||
| 370 | type: "path" as const, | ||
| 371 | path: entry.path, | ||
| 372 | kind: entry.kind, | ||
| 373 | label: parts[parts.length - 1] ?? entry.path, | ||
| 374 | description: parts.length > 1 ? parts.slice(0, -1).join("/") : "", | ||
| 375 | }; | ||
| 376 | }); | ||
| 377 | } | ||
| 378 | |||
| 379 | return []; | ||
| 380 | }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); | ||
| 381 | |||
| 382 | // ── Handle command selection ────────────────────────────── | ||
| 383 | const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; | ||
| 384 | |||
| 385 | const handleSend = useCallback(() => { | ||
| 386 | onSendMessage(); | ||
| 387 | inputRef.current?.blur(); | ||
| 388 | }, [onSendMessage]); | ||
| 389 | const handleCommandSelect = useCallback( | ||
| 390 | (item: ComposerCommandItem) => { | ||
| 391 | if (!composerTrigger) return; | ||
| 392 | |||
| 393 | if ( | ||
| 394 | item.type === "slash-command" && | ||
| 395 | (item.command === "plan" || item.command === "default") | ||
| 396 | ) { | ||
| 397 | const result = replaceTextRange( | ||
| 398 | draftMessage, | ||
| 399 | composerTrigger.rangeStart, | ||
| 400 | composerTrigger.rangeEnd, | ||
| 401 | "", | ||
| 402 | ); | ||
| 403 | setCursorPosition(result.cursor); | ||
| 404 | onChangeDraftMessage(result.text); | ||
| 405 | void onUpdateInteractionMode(item.command); | ||
| 406 | return; | ||
| 407 | } | ||
| 408 | |||
| 409 | let replacement = ""; | ||
| 410 | if (item.type === "path") { | ||
| 411 | replacement = `@${item.path} `; | ||
| 412 | } else if (item.type === "skill") { | ||
| 413 | replacement = `$${item.skill.name} `; | ||
| 414 | } else if (item.type === "slash-command") { | ||
| 415 | replacement = `/${item.command} `; | ||
| 416 | } else if (item.type === "provider-slash-command") { | ||
| 417 | replacement = `/${item.command.name} `; | ||
| 418 | } | ||
| 419 | |||
| 420 | const result = replaceTextRange( | ||
| 421 | draftMessage, | ||
| 422 | composerTrigger.rangeStart, | ||
| 423 | composerTrigger.rangeEnd, | ||
| 424 | replacement, | ||
| 425 | ); | ||
| 426 | setCursorPosition(result.cursor); | ||
| 427 | onChangeDraftMessage(result.text); | ||
| 428 | }, | ||
| 429 | [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], | ||
| 430 | ); | ||
| 431 | |||
| 432 | // ── Model menu ─────────────────────────────────────────── | ||
| 433 | const providerGroups = useMemo(() => { | ||
| 434 | const options = buildModelOptions(props.serverConfig, currentModelSelection); | ||
| 435 | return groupByProvider(options); | ||
| 436 | }, [props.serverConfig, currentModelSelection]); | ||
| 437 | |||
| 438 | const modelMenuActions = useMemo( | ||
| 439 | () => | ||
| 440 | providerGroups.map((group) => ({ | ||
| 441 | id: `provider:${group.providerKey}`, | ||
| 442 | title: group.providerLabel, | ||
| 443 | subtitle: group.models.find( | ||
| 444 | (model) => | ||
| 445 | model.selection.instanceId === currentModelSelection.instanceId && | ||
| 446 | model.selection.model === currentModelSelection.model, | ||
| 447 | )?.label, | ||
| 448 | subactions: group.models.map((option) => ({ | ||
| 449 | id: `model:${option.key}`, | ||
| 450 | title: option.label, | ||
| 451 | state: | ||
| 452 | option.selection.instanceId === currentModelSelection.instanceId && | ||
| 453 | option.selection.model === currentModelSelection.model | ||
| 454 | ? ("on" as const) | ||
| 455 | : undefined, | ||
| 456 | })), | ||
| 457 | })), | ||
| 458 | [providerGroups, currentModelSelection], | ||
| 459 | ); | ||
| 460 | |||
| 461 | // ── Options menu ───────────────────────────────────────── | ||
| 462 | const optionsMenuActions = useMemo( | ||
| 463 | () => [ | ||
| 464 | { | ||
| 465 | id: "options-effort", | ||
| 466 | title: "Effort", | ||
| 467 | subtitle: `${currentEffort.charAt(0).toUpperCase()}${currentEffort.slice(1)}`, | ||
| 468 | subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ | ||
| 469 | id: `options:effort:${level}`, | ||
| 470 | title: `${level}${level === "high" ? " (default)" : ""}`, | ||
| 471 | state: currentEffort === level ? ("on" as const) : undefined, | ||
| 472 | })), | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | id: "options-fast-mode", | ||
| 476 | title: "Fast Mode", | ||
| 477 | subtitle: currentFastMode ? "On" : "Off", | ||
| 478 | subactions: ([false, true] as const).map((value) => ({ | ||
| 479 | id: `options:fast-mode:${value ? "on" : "off"}`, | ||
| 480 | title: value ? "On" : "Off", | ||
| 481 | state: currentFastMode === value ? ("on" as const) : undefined, | ||
| 482 | })), | ||
| 483 | }, | ||
| 484 | { | ||
| 485 | id: "options-context-window", | ||
| 486 | title: "Context Window", | ||
| 487 | subtitle: currentContextWindow, | ||
| 488 | subactions: (["200k", "1M"] as const).map((value) => ({ | ||
| 489 | id: `options:context-window:${value}`, | ||
| 490 | title: `${value}${value === "1M" ? " (default)" : ""}`, | ||
| 491 | state: currentContextWindow === value ? ("on" as const) : undefined, | ||
| 492 | })), | ||
| 493 | }, | ||
| 494 | { | ||
| 495 | id: "options-runtime", | ||
| 496 | title: "Runtime", | ||
| 497 | subtitle: | ||
| 498 | currentRuntimeMode === "approval-required" | ||
| 499 | ? "Approve actions" | ||
| 500 | : currentRuntimeMode === "auto-accept-edits" | ||
| 501 | ? "Auto-accept edits" | ||
| 502 | : "Full access", | ||
| 503 | subactions: [ | ||
| 504 | { id: "options:runtime:approval-required", title: "Approve actions" }, | ||
| 505 | { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, | ||
| 506 | { id: "options:runtime:full-access", title: "Full access" }, | ||
| 507 | ].map((option) => { | ||
| 508 | const value = option.id.replace("options:runtime:", ""); | ||
| 509 | return { | ||
| 510 | id: option.id, | ||
| 511 | title: option.title, | ||
| 512 | state: currentRuntimeMode === value ? ("on" as const) : undefined, | ||
| 513 | }; | ||
| 514 | }), | ||
| 515 | }, | ||
| 516 | { | ||
| 517 | id: "options-interaction", | ||
| 518 | title: "Interaction", | ||
| 519 | subtitle: currentInteractionMode === "plan" ? "Plan" : "Default", | ||
| 520 | subactions: [ | ||
| 521 | { id: "options:interaction:default", title: "Default" }, | ||
| 522 | { id: "options:interaction:plan", title: "Plan" }, | ||
| 523 | ].map((option) => { | ||
| 524 | const value = option.id.replace("options:interaction:", ""); | ||
| 525 | return { | ||
| 526 | id: option.id, | ||
| 527 | title: option.title, | ||
| 528 | state: currentInteractionMode === value ? ("on" as const) : undefined, | ||
| 529 | }; | ||
| 530 | }), | ||
| 531 | }, | ||
| 532 | ], | ||
| 533 | [ | ||
| 534 | currentEffort, | ||
| 535 | currentFastMode, | ||
| 536 | currentContextWindow, | ||
| 537 | currentRuntimeMode, | ||
| 538 | currentInteractionMode, | ||
| 539 | ], | ||
| 540 | ); | ||
| 541 | |||
| 542 | // ── Menu handlers ──────────────────────────────────────── | ||
| 543 | function handleModelMenuAction(event: string) { | ||
| 544 | if (!event.startsWith("model:")) { | ||
| 545 | return; | ||
| 546 | } | ||
| 547 | const modelKey = event.slice("model:".length); | ||
| 548 | const options = buildModelOptions(props.serverConfig, currentModelSelection); | ||
| 549 | const option = options.find((o) => o.key === modelKey); | ||
| 550 | if (option) { | ||
| 551 | void props.onUpdateModelSelection(option.selection); | ||
| 552 | } | ||
| 553 | } | ||
| 554 | |||
| 555 | function handleOptionsMenuAction(event: string) { | ||
| 556 | if (event.startsWith("options:effort:")) { | ||
| 557 | const effort = event.slice("options:effort:".length); | ||
| 558 | const updated: ModelSelection = | ||
| 559 | selectedProviderDriver === "claudeAgent" | ||
| 560 | ? withModelSelectionOption( | ||
| 561 | currentModelSelection, | ||
| 562 | "effort", | ||
| 563 | effort as typeof currentEffort, | ||
| 564 | ) | ||
| 565 | : currentModelSelection; | ||
| 566 | void props.onUpdateModelSelection(updated); | ||
| 567 | return; | ||
| 568 | } | ||
| 569 | if (event.startsWith("options:fast-mode:")) { | ||
| 570 | const fastMode = event.endsWith(":on"); | ||
| 571 | const nextFast = fastMode || undefined; | ||
| 572 | if (selectedProviderDriver === "opencode") { | ||
| 573 | return; | ||
| 574 | } | ||
| 575 | const updated = withModelSelectionOption(currentModelSelection, "fastMode", nextFast); | ||
| 576 | void props.onUpdateModelSelection(updated); | ||
| 577 | return; | ||
| 578 | } | ||
| 579 | if (event.startsWith("options:context-window:")) { | ||
| 580 | const contextWindow = event.slice("options:context-window:".length); | ||
| 581 | const updated: ModelSelection = | ||
| 582 | selectedProviderDriver === "claudeAgent" | ||
| 583 | ? withModelSelectionOption(currentModelSelection, "contextWindow", contextWindow) | ||
| 584 | : currentModelSelection; | ||
| 585 | void props.onUpdateModelSelection(updated); | ||
| 586 | return; | ||
| 587 | } | ||
| 588 | if (event.startsWith("options:runtime:")) { | ||
| 589 | const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode; | ||
| 590 | void props.onUpdateRuntimeMode(runtimeMode); | ||
| 591 | return; | ||
| 592 | } | ||
| 593 | if (event.startsWith("options:interaction:")) { | ||
| 594 | const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode; | ||
| 595 | void props.onUpdateInteractionMode(interactionMode); | ||
| 596 | } | ||
| 597 | } | ||
| 598 | |||
| 599 | return ( | ||
| 600 | <View | ||
| 601 | style={{ | ||
| 602 | paddingHorizontal: 16, | ||
| 603 | paddingTop: isExpanded ? 12 : 10, | ||
| 604 | paddingBottom: (props.bottomInset ?? 0) + (isExpanded ? 4 : 10), | ||
| 605 | experimental_backgroundImage: isDarkMode | ||
| 606 | ? "linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 40%, rgba(0,0,0,0.95) 100%)" | ||
| 607 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 40%, rgba(255,255,255,0.95) 100%)", | ||
| 608 | }} | ||
| 609 | > | ||
| 610 | <View className="w-full" style={{ position: "relative" }}> | ||
| 611 | {composerTrigger && composerMenuItems.length > 0 ? ( | ||
| 612 | <View | ||
| 613 | style={{ | ||
| 614 | position: "absolute", | ||
| 615 | bottom: "100%", | ||
| 616 | left: 0, | ||
| 617 | right: 0, | ||
| 618 | marginBottom: 8, | ||
| 619 | zIndex: 10, | ||
| 620 | }} | ||
| 621 | > | ||
| 622 | <ComposerCommandPopover | ||
| 623 | items={composerMenuItems} | ||
| 624 | triggerKind={composerTrigger.kind} | ||
| 625 | isLoading={pathSearch.isPending} | ||
| 626 | onSelect={handleCommandSelect} | ||
| 627 | /> | ||
| 628 | </View> | ||
| 629 | ) : null} | ||
| 630 | |||
| 631 | <ComposerSurface | ||
| 632 | isDarkMode={isDarkMode} | ||
| 633 | style={ | ||
| 634 | isExpanded | ||
| 635 | ? { | ||
| 636 | borderRadius: 20, | ||
| 637 | overflow: "hidden" as const, | ||
| 638 | paddingHorizontal: 14, | ||
| 639 | paddingVertical: 12, | ||
| 640 | } | ||
| 641 | : { | ||
| 642 | borderRadius: 999, | ||
| 643 | overflow: "hidden" as const, | ||
| 644 | flexDirection: "row" as const, | ||
| 645 | alignItems: "center" as const, | ||
| 646 | paddingLeft: 18, | ||
| 647 | paddingRight: 5, | ||
| 648 | paddingVertical: 5, | ||
| 649 | } | ||
| 650 | } | ||
| 651 | > | ||
| 652 | {/* Attachment strip — inside the card, above the text input */} | ||
| 653 | {isExpanded ? ( | ||
| 654 | <View style={{ paddingBottom: props.draftAttachments.length > 0 ? 10 : 0 }}> | ||
| 655 | <ComposerAttachmentStrip | ||
| 656 | attachments={props.draftAttachments} | ||
| 657 | onRemove={props.onRemoveDraftImage} | ||
| 658 | onPressImage={onPressImage} | ||
| 659 | /> | ||
| 660 | </View> | ||
| 661 | ) : null} | ||
| 662 | |||
| 663 | <View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}> | ||
| 664 | <TextInputWrapper onPaste={handleNativePaste}> | ||
| 665 | <RNTextInput | ||
| 666 | ref={inputRef} | ||
| 667 | multiline | ||
| 668 | value={props.draftMessage} | ||
| 669 | onChangeText={props.onChangeDraftMessage} | ||
| 670 | onSelectionChange={handleSelectionChange} | ||
| 671 | placeholder={props.placeholder} | ||
| 672 | placeholderTextColor={placeholderColor} | ||
| 673 | editable={props.connectionState === "ready"} | ||
| 674 | onFocus={() => setIsFocused(true)} | ||
| 675 | onBlur={() => setIsFocused(false)} | ||
| 676 | textAlignVertical={isExpanded ? "top" : "center"} | ||
| 677 | style={ | ||
| 678 | isExpanded | ||
| 679 | ? { | ||
| 680 | minHeight: 80, | ||
| 681 | maxHeight: 160, | ||
| 682 | paddingHorizontal: 4, | ||
| 683 | paddingVertical: 4, | ||
| 684 | fontSize: 15, | ||
| 685 | lineHeight: 22, | ||
| 686 | color: foregroundColor, | ||
| 687 | fontFamily: "DMSans_400Regular", | ||
| 688 | } | ||
| 689 | : { | ||
| 690 | maxHeight: 36, | ||
| 691 | paddingVertical: 6, | ||
| 692 | fontSize: 15, | ||
| 693 | lineHeight: 20, | ||
| 694 | color: foregroundColor, | ||
| 695 | fontFamily: "DMSans_400Regular", | ||
| 696 | } | ||
| 697 | } | ||
| 698 | /> | ||
| 699 | </TextInputWrapper> | ||
| 700 | </View> | ||
| 701 | {!isExpanded && props.draftAttachments.length > 0 ? ( | ||
| 702 | <View style={{ flexDirection: "row", gap: 4, paddingLeft: 4 }}> | ||
| 703 | {props.draftAttachments.slice(0, 3).map((image) => ( | ||
| 704 | <Pressable key={image.id} onPress={() => onPressImage(image.previewUri)}> | ||
| 705 | <Image | ||
| 706 | source={{ uri: image.previewUri }} | ||
| 707 | className="bg-subtle" | ||
| 708 | style={{ | ||
| 709 | width: 30, | ||
| 710 | height: 30, | ||
| 711 | borderRadius: 8, | ||
| 712 | }} | ||
| 713 | resizeMode="cover" | ||
| 714 | /> | ||
| 715 | </Pressable> | ||
| 716 | ))} | ||
| 717 | {props.draftAttachments.length > 3 ? ( | ||
| 718 | <View | ||
| 719 | className="bg-subtle-strong" | ||
| 720 | style={{ | ||
| 721 | width: 30, | ||
| 722 | height: 30, | ||
| 723 | borderRadius: 8, | ||
| 724 | alignItems: "center", | ||
| 725 | justifyContent: "center", | ||
| 726 | }} | ||
| 727 | > | ||
| 728 | <Text className="text-foreground-muted text-[11px] font-t3-bold"> | ||
| 729 | +{props.draftAttachments.length - 3} | ||
| 730 | </Text> | ||
| 731 | </View> | ||
| 732 | ) : null} | ||
| 733 | </View> | ||
| 734 | ) : null} | ||
| 735 | {!isExpanded ? ( | ||
| 736 | showStopAction ? ( | ||
| 737 | <ControlPill | ||
| 738 | icon="stop.fill" | ||
| 739 | variant="danger" | ||
| 740 | onPress={() => void props.onStopThread()} | ||
| 741 | /> | ||
| 742 | ) : ( | ||
| 743 | <ControlPill | ||
| 744 | icon="arrow.up" | ||
| 745 | variant="primary" | ||
| 746 | disabled={!canSend} | ||
| 747 | onPress={handleSend} | ||
| 748 | /> | ||
| 749 | ) | ||
| 750 | ) : null} | ||
| 751 | </ComposerSurface> | ||
| 752 | |||
| 753 | {/* Toolbar row — matches draft page layout (expanded only) */} | ||
| 754 | {isExpanded ? ( | ||
| 755 | <View | ||
| 756 | style={{ | ||
| 757 | flexDirection: "row", | ||
| 758 | alignItems: "center", | ||
| 759 | justifyContent: "space-between", | ||
| 760 | paddingTop: 10, | ||
| 761 | gap: 8, | ||
| 762 | }} | ||
| 763 | > | ||
| 764 | <ControlPill icon="plus" onPress={() => void props.onPickDraftImages()} /> | ||
| 765 | <ControlPillMenu | ||
| 766 | actions={modelMenuActions} | ||
| 767 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} | ||
| 768 | > | ||
| 769 | <ControlPill iconNode={<ProviderIcon provider={modelProvider} size={16} />} /> | ||
| 770 | </ControlPillMenu> | ||
| 771 | <ControlPillMenu | ||
| 772 | actions={optionsMenuActions} | ||
| 773 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} | ||
| 774 | > | ||
| 775 | <ControlPill icon="slider.horizontal.3" /> | ||
| 776 | </ControlPillMenu> | ||
| 777 | <ControlPill icon="arrow.clockwise" onPress={() => void props.onRefresh()} /> | ||
| 778 | {showStopAction ? ( | ||
| 779 | <ControlPill | ||
| 780 | icon="stop.fill" | ||
| 781 | variant="danger" | ||
| 782 | onPress={() => void props.onStopThread()} | ||
| 783 | /> | ||
| 784 | ) : null} | ||
| 785 | <ControlPill | ||
| 786 | icon="arrow.up" | ||
| 787 | label={sendLabel} | ||
| 788 | variant="primary" | ||
| 789 | disabled={!canSend} | ||
| 790 | onPress={handleSend} | ||
| 791 | /> | ||
| 792 | </View> | ||
| 793 | ) : null} | ||
| 794 | |||
| 795 | {/* Queue count */} | ||
| 796 | {props.queueCount > 0 ? ( | ||
| 797 | <Text | ||
| 798 | className="text-foreground-muted" | ||
| 799 | style={{ | ||
| 800 | fontSize: 12, | ||
| 801 | lineHeight: 18, | ||
| 802 | paddingTop: 8, | ||
| 803 | }} | ||
| 804 | > | ||
| 805 | {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send | ||
| 806 | automatically. | ||
| 807 | </Text> | ||
| 808 | ) : null} | ||
| 809 | </View> | ||
| 810 | |||
| 811 | <ImageViewing | ||
| 812 | images={previewImageUri ? [{ uri: previewImageUri }] : []} | ||
| 813 | imageIndex={0} | ||
| 814 | visible={previewImageUri !== null} | ||
| 815 | onRequestClose={closePreview} | ||
| 816 | swipeToCloseEnabled | ||
| 817 | doubleTapToZoomEnabled | ||
| 818 | /> | ||
| 819 | </View> | ||
| 820 | ); | ||
| 821 | }); | ||
apps/mobile/src/features/threads/ThreadDetailScreen.tsx created+347| ... | @@ -0,0 +1,347 @@ | ||
| 1 | import type { | ||
| 2 | ApprovalRequestId, | ||
| 3 | EnvironmentId, | ||
| 4 | ModelSelection, | ||
| 5 | OrchestrationThread, | ||
| 6 | ProviderApprovalDecision, | ||
| 7 | ProviderInteractionMode, | ||
| 8 | RuntimeMode, | ||
| 9 | ServerConfig as T3ServerConfig, | ||
| 10 | ThreadId, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | ||
| 13 | import * as Haptics from "expo-haptics"; | ||
| 14 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 15 | import { View, type LayoutChangeEvent } from "react-native"; | ||
| 16 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | ||
| 17 | import { KeyboardStickyView } from "react-native-keyboard-controller"; | ||
| 18 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 19 | import { runOnJS } from "react-native-reanimated"; | ||
| 20 | |||
| 21 | import { AppText as Text } from "../../components/AppText"; | ||
| 22 | import type { StatusTone } from "../../components/StatusPill"; | ||
| 23 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | ||
| 24 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | ||
| 25 | import type { | ||
| 26 | PendingApproval, | ||
| 27 | PendingUserInput, | ||
| 28 | PendingUserInputDraftAnswer, | ||
| 29 | ThreadFeedEntry, | ||
| 30 | } from "../../lib/threadActivity"; | ||
| 31 | import { PendingApprovalCard } from "./PendingApprovalCard"; | ||
| 32 | import { PendingUserInputCard } from "./PendingUserInputCard"; | ||
| 33 | import { | ||
| 34 | COMPOSER_COLLAPSED_CHROME, | ||
| 35 | COMPOSER_EXPANDED_CHROME, | ||
| 36 | COMPOSER_EXPANDED_TOOLBAR_CHROME, | ||
| 37 | ThreadComposer, | ||
| 38 | } from "./ThreadComposer"; | ||
| 39 | import { ThreadFeed } from "./ThreadFeed"; | ||
| 40 | |||
| 41 | export interface ThreadDetailScreenProps { | ||
| 42 | readonly selectedThread: OrchestrationThread; | ||
| 43 | readonly screenTone: StatusTone; | ||
| 44 | readonly connectionError: string | null; | ||
| 45 | readonly httpBaseUrl: string | null; | ||
| 46 | readonly bearerToken: string | null; | ||
| 47 | readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; | ||
| 48 | readonly activeWorkStartedAt: string | null; | ||
| 49 | readonly activePendingApproval: PendingApproval | null; | ||
| 50 | readonly respondingApprovalId: ApprovalRequestId | null; | ||
| 51 | readonly activePendingUserInput: PendingUserInput | null; | ||
| 52 | readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>; | ||
| 53 | readonly activePendingUserInputAnswers: Record<string, string> | null; | ||
| 54 | readonly respondingUserInputId: ApprovalRequestId | null; | ||
| 55 | readonly draftMessage: string; | ||
| 56 | readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 57 | readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; | ||
| 58 | readonly activeThreadBusy: boolean; | ||
| 59 | readonly environmentId: EnvironmentId; | ||
| 60 | readonly projectWorkspaceRoot: string | null; | ||
| 61 | readonly selectedThreadQueueCount: number; | ||
| 62 | readonly serverConfig: T3ServerConfig | null; | ||
| 63 | readonly layoutVariant?: MobileLayoutVariant; | ||
| 64 | readonly onOpenDrawer: () => void; | ||
| 65 | readonly onOpenConnectionEditor: () => void; | ||
| 66 | readonly onChangeDraftMessage: (value: string) => void; | ||
| 67 | readonly onPickDraftImages: () => Promise<void>; | ||
| 68 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | ||
| 69 | readonly onRemoveDraftImage: (imageId: string) => void; | ||
| 70 | readonly onRefresh: () => Promise<void>; | ||
| 71 | readonly onStopThread: () => Promise<void>; | ||
| 72 | readonly onSendMessage: () => void; | ||
| 73 | readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>; | ||
| 74 | readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise<void>; | ||
| 75 | readonly onUpdateThreadInteractionMode: ( | ||
| 76 | interactionMode: ProviderInteractionMode, | ||
| 77 | ) => Promise<void>; | ||
| 78 | readonly onRespondToApproval: ( | ||
| 79 | requestId: ApprovalRequestId, | ||
| 80 | decision: ProviderApprovalDecision, | ||
| 81 | ) => Promise<void>; | ||
| 82 | readonly onSelectUserInputOption: ( | ||
| 83 | requestId: ApprovalRequestId, | ||
| 84 | questionId: string, | ||
| 85 | label: string, | ||
| 86 | ) => void; | ||
| 87 | readonly onChangeUserInputCustomAnswer: ( | ||
| 88 | requestId: ApprovalRequestId, | ||
| 89 | questionId: string, | ||
| 90 | customAnswer: string, | ||
| 91 | ) => void; | ||
| 92 | readonly onSubmitUserInput: () => Promise<void>; | ||
| 93 | readonly showContent?: boolean; | ||
| 94 | } | ||
| 95 | |||
| 96 | function latestStreamingAssistantMessage( | ||
| 97 | feed: ReadonlyArray<ThreadFeedEntry>, | ||
| 98 | ): { readonly id: string; readonly textLength: number } | null { | ||
| 99 | for (let index = feed.length - 1; index >= 0; index -= 1) { | ||
| 100 | const entry = feed[index]; | ||
| 101 | if (entry?.type !== "message") { | ||
| 102 | continue; | ||
| 103 | } | ||
| 104 | if (entry.message.role !== "assistant" || !entry.message.streaming) { | ||
| 105 | continue; | ||
| 106 | } | ||
| 107 | return { | ||
| 108 | id: entry.message.id, | ||
| 109 | textLength: entry.message.text.length, | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | return null; | ||
| 114 | } | ||
| 115 | |||
| 116 | function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedEntry>) { | ||
| 117 | const lastStreamingAssistantRef = useRef<{ | ||
| 118 | readonly id: string; | ||
| 119 | readonly textLength: number; | ||
| 120 | } | null>(null); | ||
| 121 | const lastStreamHapticAtRef = useRef(0); | ||
| 122 | const hydratedRef = useRef(false); | ||
| 123 | const previousThreadIdRef = useRef(threadId); | ||
| 124 | |||
| 125 | useEffect(() => { | ||
| 126 | if (previousThreadIdRef.current !== threadId) { | ||
| 127 | previousThreadIdRef.current = threadId; | ||
| 128 | hydratedRef.current = false; | ||
| 129 | } | ||
| 130 | |||
| 131 | const latestStreamingMessage = latestStreamingAssistantMessage(feed); | ||
| 132 | |||
| 133 | if (!hydratedRef.current) { | ||
| 134 | hydratedRef.current = true; | ||
| 135 | lastStreamingAssistantRef.current = latestStreamingMessage; | ||
| 136 | return; | ||
| 137 | } | ||
| 138 | |||
| 139 | if (!latestStreamingMessage) { | ||
| 140 | lastStreamingAssistantRef.current = null; | ||
| 141 | return; | ||
| 142 | } | ||
| 143 | |||
| 144 | const previousStreamingMessage = lastStreamingAssistantRef.current; | ||
| 145 | lastStreamingAssistantRef.current = latestStreamingMessage; | ||
| 146 | |||
| 147 | const isNewStream = previousStreamingMessage?.id !== latestStreamingMessage.id; | ||
| 148 | const textGrew = | ||
| 149 | previousStreamingMessage?.id === latestStreamingMessage.id && | ||
| 150 | latestStreamingMessage.textLength > previousStreamingMessage.textLength; | ||
| 151 | |||
| 152 | if (!isNewStream && !textGrew) { | ||
| 153 | return; | ||
| 154 | } | ||
| 155 | |||
| 156 | const now = Date.now(); | ||
| 157 | if (!isNewStream && now - lastStreamHapticAtRef.current < 320) { | ||
| 158 | return; | ||
| 159 | } | ||
| 160 | |||
| 161 | lastStreamHapticAtRef.current = now; | ||
| 162 | void Haptics.selectionAsync(); | ||
| 163 | }, [threadId, feed]); | ||
| 164 | } | ||
| 165 | |||
| 166 | const WORKING_INDICATOR_HEIGHT = 44; | ||
| 167 | |||
| 168 | const WorkingDurationPill = memo(function WorkingDurationPill(props: { | ||
| 169 | readonly startedAt: string; | ||
| 170 | }) { | ||
| 171 | const [nowMs, setNowMs] = useState(() => Date.now()); | ||
| 172 | |||
| 173 | useEffect(() => { | ||
| 174 | const intervalId = setInterval(() => { | ||
| 175 | setNowMs(Date.now()); | ||
| 176 | }, 1_000); | ||
| 177 | return () => clearInterval(intervalId); | ||
| 178 | }, [props.startedAt]); | ||
| 179 | |||
| 180 | const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; | ||
| 181 | |||
| 182 | return ( | ||
| 183 | <View className="px-4 pb-2" style={{ flexShrink: 0 }}> | ||
| 184 | <View className="self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]"> | ||
| 185 | <View className="flex-row items-center gap-2"> | ||
| 186 | <View className="flex-row items-center gap-1"> | ||
| 187 | <View className="h-1.5 w-1.5 rounded-full bg-neutral-400 dark:bg-neutral-500" /> | ||
| 188 | <View className="h-1.5 w-1.5 rounded-full bg-neutral-400/80 dark:bg-neutral-500/80" /> | ||
| 189 | <View className="h-1.5 w-1.5 rounded-full bg-neutral-400/60 dark:bg-neutral-500/60" /> | ||
| 190 | </View> | ||
| 191 | <Text className="font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> | ||
| 192 | Working for {durationLabel} | ||
| 193 | </Text> | ||
| 194 | </View> | ||
| 195 | </View> | ||
| 196 | </View> | ||
| 197 | ); | ||
| 198 | }); | ||
| 199 | |||
| 200 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { | ||
| 201 | const { onOpenDrawer, onRefresh } = props; | ||
| 202 | |||
| 203 | const insets = useSafeAreaInsets(); | ||
| 204 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; | ||
| 205 | const composerBottomInset = Math.max(insets.bottom, 12); | ||
| 206 | const [composerExpanded, setComposerExpanded] = useState(false); | ||
| 207 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; | ||
| 208 | const composerOverlapHeight = composerChrome + composerBottomInset; | ||
| 209 | const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; | ||
| 210 | const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; | ||
| 211 | const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0); | ||
| 212 | const [refreshing, setRefreshing] = useState(false); | ||
| 213 | const showContent = props.showContent ?? true; | ||
| 214 | const layoutVariant = props.layoutVariant ?? "compact"; | ||
| 215 | const isSplitLayout = layoutVariant === "split"; | ||
| 216 | useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); | ||
| 217 | const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0; | ||
| 218 | const feedBottomInset = | ||
| 219 | Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8; | ||
| 220 | |||
| 221 | const completeDrawerGesture = useCallback(() => { | ||
| 222 | void Haptics.selectionAsync(); | ||
| 223 | onOpenDrawer(); | ||
| 224 | }, [onOpenDrawer]); | ||
| 225 | |||
| 226 | const handleRefresh = useCallback(async (): Promise<void> => { | ||
| 227 | if (refreshing) { | ||
| 228 | return; | ||
| 229 | } | ||
| 230 | |||
| 231 | setRefreshing(true); | ||
| 232 | try { | ||
| 233 | await onRefresh(); | ||
| 234 | } finally { | ||
| 235 | setRefreshing(false); | ||
| 236 | } | ||
| 237 | }, [onRefresh, refreshing]); | ||
| 238 | |||
| 239 | const drawerGestureThreshold = 80; | ||
| 240 | const headerDrawerGesture = useMemo( | ||
| 241 | () => | ||
| 242 | Gesture.Pan() | ||
| 243 | .enabled(!isSplitLayout) | ||
| 244 | .hitSlop({ left: 0, width: 40 }) | ||
| 245 | .activeOffsetX([10, 999]) | ||
| 246 | .failOffsetY([-24, 24]) | ||
| 247 | .onEnd((event) => { | ||
| 248 | const translationX = Math.max(event.translationX, 0); | ||
| 249 | if (event.y < drawerGestureThreshold && translationX > 56) { | ||
| 250 | runOnJS(completeDrawerGesture)(); | ||
| 251 | } | ||
| 252 | }), | ||
| 253 | [completeDrawerGesture, isSplitLayout], | ||
| 254 | ); | ||
| 255 | |||
| 256 | const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => { | ||
| 257 | const nextHeight = Math.ceil(event.nativeEvent.layout.height); | ||
| 258 | setMeasuredOverlayHeight((current) => | ||
| 259 | Math.abs(current - nextHeight) > 1 ? nextHeight : current, | ||
| 260 | ); | ||
| 261 | }, []); | ||
| 262 | |||
| 263 | return ( | ||
| 264 | <GestureDetector gesture={headerDrawerGesture}> | ||
| 265 | <View className="flex-1"> | ||
| 266 | {showContent ? ( | ||
| 267 | <ThreadFeed | ||
| 268 | threadId={props.selectedThread.id} | ||
| 269 | feed={props.selectedThreadFeed} | ||
| 270 | httpBaseUrl={props.httpBaseUrl} | ||
| 271 | bearerToken={props.bearerToken} | ||
| 272 | agentLabel={agentLabel} | ||
| 273 | contentBottomInset={feedBottomInset} | ||
| 274 | layoutVariant={layoutVariant} | ||
| 275 | composerExpanded={composerExpanded} | ||
| 276 | refreshing={refreshing} | ||
| 277 | onRefresh={handleRefresh} | ||
| 278 | /> | ||
| 279 | ) : ( | ||
| 280 | <View style={{ flex: 1 }} /> | ||
| 281 | )} | ||
| 282 | |||
| 283 | {/* Floating composer — sticks to keyboard via KeyboardStickyView */} | ||
| 284 | {showContent ? ( | ||
| 285 | <KeyboardStickyView | ||
| 286 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | ||
| 287 | offset={{ closed: 0, opened: 0 }} | ||
| 288 | > | ||
| 289 | <View onLayout={handleOverlayLayout}> | ||
| 290 | {props.activeWorkStartedAt ? ( | ||
| 291 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> | ||
| 292 | ) : null} | ||
| 293 | |||
| 294 | {props.activePendingApproval || props.activePendingUserInput ? ( | ||
| 295 | <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> | ||
| 296 | {props.activePendingApproval ? ( | ||
| 297 | <PendingApprovalCard | ||
| 298 | approval={props.activePendingApproval} | ||
| 299 | respondingApprovalId={props.respondingApprovalId} | ||
| 300 | onRespond={props.onRespondToApproval} | ||
| 301 | /> | ||
| 302 | ) : null} | ||
| 303 | {props.activePendingUserInput ? ( | ||
| 304 | <PendingUserInputCard | ||
| 305 | pendingUserInput={props.activePendingUserInput} | ||
| 306 | drafts={props.activePendingUserInputDrafts} | ||
| 307 | answers={props.activePendingUserInputAnswers} | ||
| 308 | respondingUserInputId={props.respondingUserInputId} | ||
| 309 | onSelectOption={props.onSelectUserInputOption} | ||
| 310 | onChangeCustomAnswer={props.onChangeUserInputCustomAnswer} | ||
| 311 | onSubmit={props.onSubmitUserInput} | ||
| 312 | /> | ||
| 313 | ) : null} | ||
| 314 | </View> | ||
| 315 | ) : null} | ||
| 316 | |||
| 317 | <ThreadComposer | ||
| 318 | draftMessage={props.draftMessage} | ||
| 319 | draftAttachments={props.draftAttachments} | ||
| 320 | placeholder="Ask the repo agent, or run a command…" | ||
| 321 | connectionState={props.connectionStateLabel} | ||
| 322 | selectedThread={props.selectedThread} | ||
| 323 | serverConfig={props.serverConfig} | ||
| 324 | queueCount={props.selectedThreadQueueCount} | ||
| 325 | activeThreadBusy={props.activeThreadBusy} | ||
| 326 | environmentId={props.environmentId} | ||
| 327 | projectCwd={props.projectWorkspaceRoot} | ||
| 328 | bottomInset={composerBottomInset} | ||
| 329 | onChangeDraftMessage={props.onChangeDraftMessage} | ||
| 330 | onPickDraftImages={props.onPickDraftImages} | ||
| 331 | onNativePasteImages={props.onNativePasteImages} | ||
| 332 | onRemoveDraftImage={props.onRemoveDraftImage} | ||
| 333 | onRefresh={props.onRefresh} | ||
| 334 | onStopThread={props.onStopThread} | ||
| 335 | onSendMessage={props.onSendMessage} | ||
| 336 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | ||
| 337 | onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} | ||
| 338 | onUpdateInteractionMode={props.onUpdateThreadInteractionMode} | ||
| 339 | onExpandedChange={setComposerExpanded} | ||
| 340 | /> | ||
| 341 | </View> | ||
| 342 | </KeyboardStickyView> | ||
| 343 | ) : null} | ||
| 344 | </View> | ||
| 345 | </GestureDetector> | ||
| 346 | ); | ||
| 347 | }); | ||
apps/mobile/src/features/threads/ThreadFeed.tsx created+989| ... | @@ -0,0 +1,989 @@ | ||
| 1 | import * as Clipboard from "expo-clipboard"; | ||
| 2 | import * as Haptics from "expo-haptics"; | ||
| 3 | import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; | ||
| 4 | import { type LegendListRef } from "@legendapp/list/react-native"; | ||
| 5 | import type { ThreadId } from "@t3tools/contracts"; | ||
| 6 | import { SymbolView } from "expo-symbols"; | ||
| 7 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 8 | import { | ||
| 9 | Markdown, | ||
| 10 | type CustomRenderers, | ||
| 11 | type NodeStyleOverrides, | ||
| 12 | type PartialMarkdownTheme, | ||
| 13 | } from "react-native-nitro-markdown"; | ||
| 14 | import { | ||
| 15 | Image, | ||
| 16 | Pressable, | ||
| 17 | ScrollView, | ||
| 18 | StyleSheet, | ||
| 19 | Text as NativeText, | ||
| 20 | type ColorValue, | ||
| 21 | useColorScheme, | ||
| 22 | useWindowDimensions, | ||
| 23 | View, | ||
| 24 | } from "react-native"; | ||
| 25 | import { TouchableOpacity } from "react-native-gesture-handler"; | ||
| 26 | import ImageViewing from "react-native-image-viewing"; | ||
| 27 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 28 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 29 | |||
| 30 | import { AppText as Text } from "../../components/AppText"; | ||
| 31 | import { EmptyState } from "../../components/EmptyState"; | ||
| 32 | import { | ||
| 33 | parseReviewCommentMessageSegments, | ||
| 34 | type ReviewInlineComment, | ||
| 35 | } from "../review/reviewCommentSelection"; | ||
| 36 | import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; | ||
| 37 | import { | ||
| 38 | buildNativeReviewDiffData, | ||
| 39 | createNativeReviewDiffTheme, | ||
| 40 | NATIVE_REVIEW_DIFF_CONTENT_WIDTH, | ||
| 41 | NATIVE_REVIEW_DIFF_ROW_HEIGHT, | ||
| 42 | NATIVE_REVIEW_DIFF_STYLE, | ||
| 43 | } from "../review/nativeReviewDiffAdapter"; | ||
| 44 | import { buildReviewParsedDiff } from "../review/reviewModel"; | ||
| 45 | import { cn } from "../../lib/cn"; | ||
| 46 | import type { MobileLayoutVariant } from "../../lib/mobileLayout"; | ||
| 47 | import type { ThreadFeedEntry } from "../../lib/threadActivity"; | ||
| 48 | import { relativeTime } from "../../lib/time"; | ||
| 49 | import { messageImageUrl } from "./threadPresentation"; | ||
| 50 | |||
| 51 | export interface ThreadFeedProps { | ||
| 52 | readonly threadId: ThreadId; | ||
| 53 | readonly feed: ReadonlyArray<ThreadFeedEntry>; | ||
| 54 | readonly httpBaseUrl: string | null; | ||
| 55 | readonly bearerToken: string | null; | ||
| 56 | readonly agentLabel: string; | ||
| 57 | readonly contentBottomInset?: number; | ||
| 58 | readonly refreshing?: boolean; | ||
| 59 | readonly onRefresh?: () => void; | ||
| 60 | readonly layoutVariant?: MobileLayoutVariant; | ||
| 61 | readonly composerExpanded?: boolean; | ||
| 62 | } | ||
| 63 | |||
| 64 | function stripShellWrapper(value: string): string { | ||
| 65 | const trimmed = value.trim(); | ||
| 66 | const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); | ||
| 67 | return (match?.[1] ?? trimmed).trim(); | ||
| 68 | } | ||
| 69 | |||
| 70 | function compactActivityDetail(detail: string | null): string | null { | ||
| 71 | if (!detail) { | ||
| 72 | return null; | ||
| 73 | } | ||
| 74 | |||
| 75 | const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); | ||
| 76 | return cleaned.length > 0 ? cleaned : null; | ||
| 77 | } | ||
| 78 | |||
| 79 | function buildActivityRows( | ||
| 80 | activities: ReadonlyArray<{ | ||
| 81 | readonly id: string; | ||
| 82 | readonly createdAt: string; | ||
| 83 | readonly summary: string; | ||
| 84 | readonly detail: string | null; | ||
| 85 | readonly status: string | null; | ||
| 86 | }>, | ||
| 87 | ) { | ||
| 88 | return activities.map<{ | ||
| 89 | id: string; | ||
| 90 | createdAt: string; | ||
| 91 | summary: string; | ||
| 92 | detail: string | null; | ||
| 93 | status: string | null; | ||
| 94 | }>((activity) => ({ | ||
| 95 | id: activity.id, | ||
| 96 | createdAt: activity.createdAt, | ||
| 97 | summary: activity.summary, | ||
| 98 | detail: compactActivityDetail(activity.detail), | ||
| 99 | status: activity.status, | ||
| 100 | })); | ||
| 101 | } | ||
| 102 | |||
| 103 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | ||
| 104 | |||
| 105 | function toMarkdownThemeColor(value: ColorValue): string { | ||
| 106 | return value as string; | ||
| 107 | } | ||
| 108 | |||
| 109 | interface MarkdownStyleSets { | ||
| 110 | readonly user: MarkdownStyleSet; | ||
| 111 | readonly assistant: MarkdownStyleSet; | ||
| 112 | } | ||
| 113 | |||
| 114 | interface MarkdownStyleSet { | ||
| 115 | readonly theme: PartialMarkdownTheme; | ||
| 116 | readonly styles: NodeStyleOverrides; | ||
| 117 | readonly renderers: CustomRenderers; | ||
| 118 | } | ||
| 119 | |||
| 120 | interface ReviewCommentColors { | ||
| 121 | readonly background: ColorValue; | ||
| 122 | readonly border: ColorValue; | ||
| 123 | readonly mutedBackground: ColorValue; | ||
| 124 | readonly text: ColorValue; | ||
| 125 | readonly mutedText: ColorValue; | ||
| 126 | readonly codeBackground: ColorValue; | ||
| 127 | } | ||
| 128 | |||
| 129 | function useReviewCommentColors(): ReviewCommentColors { | ||
| 130 | const colorScheme = useColorScheme(); | ||
| 131 | const isDark = colorScheme === "dark"; | ||
| 132 | const background = isDark ? "#151515" : "#ffffff"; | ||
| 133 | const border = isDark ? "#2a2a2a" : "#d7d7d7"; | ||
| 134 | const mutedBackground = isDark ? "#242424" : "#f2f2f2"; | ||
| 135 | const text = isDark ? "#f3f3f3" : "#111111"; | ||
| 136 | const mutedText = isDark ? "#8f8f8f" : "#666666"; | ||
| 137 | const codeBackground = isDark ? "#0f0f0f" : "#ffffff"; | ||
| 138 | |||
| 139 | return useMemo( | ||
| 140 | () => ({ | ||
| 141 | background, | ||
| 142 | border, | ||
| 143 | mutedBackground, | ||
| 144 | text, | ||
| 145 | mutedText, | ||
| 146 | codeBackground, | ||
| 147 | }), | ||
| 148 | [background, border, codeBackground, mutedBackground, mutedText, text], | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | |||
| 152 | function useMarkdownStyles(): MarkdownStyleSets { | ||
| 153 | const bodyColor = useThemeColor("--color-md-body"); | ||
| 154 | const strongColor = useThemeColor("--color-md-strong"); | ||
| 155 | const linkColor = useThemeColor("--color-md-link"); | ||
| 156 | const blockquoteBg = useThemeColor("--color-md-blockquote-bg"); | ||
| 157 | const blockquoteBorder = useThemeColor("--color-md-blockquote-border"); | ||
| 158 | const codeBg = useThemeColor("--color-md-code-bg"); | ||
| 159 | const codeText = useThemeColor("--color-md-code-text"); | ||
| 160 | const hrColor = useThemeColor("--color-md-hr"); | ||
| 161 | const userBodyColor = useThemeColor("--color-user-bubble-foreground"); | ||
| 162 | const userCodeBg = useThemeColor("--color-md-user-code-bg"); | ||
| 163 | const userCodeText = useThemeColor("--color-md-user-code-text"); | ||
| 164 | const userFenceBg = useThemeColor("--color-md-user-fence-bg"); | ||
| 165 | const userFenceText = useThemeColor("--color-md-user-fence-text"); | ||
| 166 | |||
| 167 | return useMemo(() => { | ||
| 168 | const markdownBodyColor = toMarkdownThemeColor(bodyColor); | ||
| 169 | const markdownStrongColor = toMarkdownThemeColor(strongColor); | ||
| 170 | const markdownLinkColor = toMarkdownThemeColor(linkColor); | ||
| 171 | const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg); | ||
| 172 | const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder); | ||
| 173 | const markdownCodeBg = toMarkdownThemeColor(codeBg); | ||
| 174 | const markdownCodeText = toMarkdownThemeColor(codeText); | ||
| 175 | const markdownHrColor = toMarkdownThemeColor(hrColor); | ||
| 176 | const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor); | ||
| 177 | const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg); | ||
| 178 | const markdownUserCodeText = toMarkdownThemeColor(userCodeText); | ||
| 179 | const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg); | ||
| 180 | const markdownUserFenceText = toMarkdownThemeColor(userFenceText); | ||
| 181 | |||
| 182 | const baseTheme: PartialMarkdownTheme = { | ||
| 183 | colors: { | ||
| 184 | text: markdownBodyColor, | ||
| 185 | heading: markdownStrongColor, | ||
| 186 | link: markdownLinkColor, | ||
| 187 | blockquote: markdownBlockquoteBorder, | ||
| 188 | border: markdownHrColor, | ||
| 189 | surfaceLight: markdownBlockquoteBg, | ||
| 190 | accent: markdownLinkColor, | ||
| 191 | tableBorder: markdownHrColor, | ||
| 192 | tableHeader: markdownBlockquoteBg, | ||
| 193 | tableHeaderText: markdownStrongColor, | ||
| 194 | tableRowOdd: "transparent", | ||
| 195 | tableRowEven: "transparent", | ||
| 196 | }, | ||
| 197 | spacing: { | ||
| 198 | xs: 4, | ||
| 199 | s: 4, | ||
| 200 | m: 8, | ||
| 201 | l: 8, | ||
| 202 | xl: 16, | ||
| 203 | }, | ||
| 204 | fontSizes: { | ||
| 205 | s: 13, | ||
| 206 | m: 15, | ||
| 207 | h1: 22, | ||
| 208 | h2: 19, | ||
| 209 | h3: 17, | ||
| 210 | h4: 15, | ||
| 211 | h5: 15, | ||
| 212 | h6: 15, | ||
| 213 | }, | ||
| 214 | fontFamilies: { | ||
| 215 | regular: "DMSans_400Regular", | ||
| 216 | heading: "DMSans_700Bold", | ||
| 217 | mono: "ui-monospace", | ||
| 218 | }, | ||
| 219 | headingWeight: "700", | ||
| 220 | borderRadius: { | ||
| 221 | s: 4, | ||
| 222 | m: 8, | ||
| 223 | l: 12, | ||
| 224 | }, | ||
| 225 | showCodeLanguage: false, | ||
| 226 | }; | ||
| 227 | |||
| 228 | const baseStyles: NodeStyleOverrides = { | ||
| 229 | document: { flexShrink: 1 }, | ||
| 230 | paragraph: { marginTop: 0, marginBottom: 8 }, | ||
| 231 | list: { marginTop: 4, marginBottom: 4 }, | ||
| 232 | list_item: { marginTop: 0, marginBottom: 4 }, | ||
| 233 | task_list_item: { marginTop: 0, marginBottom: 4 }, | ||
| 234 | text: { lineHeight: 22 }, | ||
| 235 | bold: { | ||
| 236 | fontWeight: "700", | ||
| 237 | color: markdownStrongColor, | ||
| 238 | fontFamily: "DMSans_700Bold", | ||
| 239 | }, | ||
| 240 | italic: { fontStyle: "italic" }, | ||
| 241 | link: { | ||
| 242 | color: markdownLinkColor, | ||
| 243 | textDecorationLine: "underline" as const, | ||
| 244 | }, | ||
| 245 | blockquote: { | ||
| 246 | borderLeftWidth: 3, | ||
| 247 | borderLeftColor: markdownBlockquoteBorder, | ||
| 248 | backgroundColor: markdownBlockquoteBg, | ||
| 249 | paddingLeft: 12, | ||
| 250 | paddingVertical: 6, | ||
| 251 | marginLeft: 0, | ||
| 252 | marginVertical: 4, | ||
| 253 | borderRadius: 4, | ||
| 254 | }, | ||
| 255 | heading: { | ||
| 256 | fontFamily: "DMSans_700Bold", | ||
| 257 | color: markdownStrongColor, | ||
| 258 | marginTop: 12, | ||
| 259 | marginBottom: 6, | ||
| 260 | }, | ||
| 261 | horizontal_rule: { | ||
| 262 | backgroundColor: markdownHrColor, | ||
| 263 | height: 1, | ||
| 264 | marginVertical: 12, | ||
| 265 | }, | ||
| 266 | }; | ||
| 267 | |||
| 268 | const createCodeRenderers = ( | ||
| 269 | inlineBackgroundColor: string, | ||
| 270 | inlineTextColor: string, | ||
| 271 | blockBackgroundColor: string, | ||
| 272 | blockTextColor: string, | ||
| 273 | ): CustomRenderers => ({ | ||
| 274 | code_inline: ({ content }) => ( | ||
| 275 | <NativeText | ||
| 276 | style={{ | ||
| 277 | backgroundColor: inlineBackgroundColor, | ||
| 278 | color: inlineTextColor, | ||
| 279 | borderRadius: 5, | ||
| 280 | paddingHorizontal: 5, | ||
| 281 | paddingVertical: 1, | ||
| 282 | fontFamily: "ui-monospace", | ||
| 283 | fontSize: 13, | ||
| 284 | }} | ||
| 285 | > | ||
| 286 | {content} | ||
| 287 | </NativeText> | ||
| 288 | ), | ||
| 289 | code_block: ({ content }) => ( | ||
| 290 | <View | ||
| 291 | style={{ | ||
| 292 | backgroundColor: blockBackgroundColor, | ||
| 293 | borderRadius: 12, | ||
| 294 | padding: 12, | ||
| 295 | marginVertical: 8, | ||
| 296 | }} | ||
| 297 | > | ||
| 298 | <ScrollView horizontal showsHorizontalScrollIndicator={false} bounces={false}> | ||
| 299 | <NativeText | ||
| 300 | selectable | ||
| 301 | style={{ | ||
| 302 | color: blockTextColor, | ||
| 303 | fontFamily: "ui-monospace", | ||
| 304 | fontSize: 13, | ||
| 305 | lineHeight: 19, | ||
| 306 | }} | ||
| 307 | > | ||
| 308 | {content} | ||
| 309 | </NativeText> | ||
| 310 | </ScrollView> | ||
| 311 | </View> | ||
| 312 | ), | ||
| 313 | }); | ||
| 314 | |||
| 315 | const userTheme: PartialMarkdownTheme = { | ||
| 316 | ...baseTheme, | ||
| 317 | colors: { | ||
| 318 | ...baseTheme.colors, | ||
| 319 | text: markdownUserBodyColor, | ||
| 320 | heading: markdownUserBodyColor, | ||
| 321 | link: markdownUserBodyColor, | ||
| 322 | code: markdownUserCodeText, | ||
| 323 | codeBackground: markdownUserCodeBg, | ||
| 324 | border: markdownUserFenceBg, | ||
| 325 | }, | ||
| 326 | }; | ||
| 327 | const userStyles: NodeStyleOverrides = { | ||
| 328 | ...baseStyles, | ||
| 329 | paragraph: { marginTop: 0, marginBottom: 0 }, | ||
| 330 | bold: { | ||
| 331 | fontWeight: "700", | ||
| 332 | color: markdownUserBodyColor, | ||
| 333 | fontFamily: "DMSans_700Bold", | ||
| 334 | }, | ||
| 335 | heading: { | ||
| 336 | ...baseStyles.heading, | ||
| 337 | color: markdownUserBodyColor, | ||
| 338 | }, | ||
| 339 | link: { | ||
| 340 | color: markdownUserBodyColor, | ||
| 341 | textDecorationLine: "underline" as const, | ||
| 342 | }, | ||
| 343 | }; | ||
| 344 | |||
| 345 | const assistantTheme: PartialMarkdownTheme = { | ||
| 346 | ...baseTheme, | ||
| 347 | colors: { | ||
| 348 | ...baseTheme.colors, | ||
| 349 | code: markdownCodeText, | ||
| 350 | codeBackground: markdownCodeBg, | ||
| 351 | border: markdownCodeBg, | ||
| 352 | }, | ||
| 353 | }; | ||
| 354 | const assistantStyles: NodeStyleOverrides = { | ||
| 355 | ...baseStyles, | ||
| 356 | }; | ||
| 357 | |||
| 358 | return { | ||
| 359 | user: { | ||
| 360 | theme: userTheme, | ||
| 361 | styles: userStyles, | ||
| 362 | renderers: createCodeRenderers( | ||
| 363 | markdownUserCodeBg, | ||
| 364 | markdownUserCodeText, | ||
| 365 | markdownUserFenceBg, | ||
| 366 | markdownUserFenceText, | ||
| 367 | ), | ||
| 368 | }, | ||
| 369 | assistant: { | ||
| 370 | theme: assistantTheme, | ||
| 371 | styles: assistantStyles, | ||
| 372 | renderers: createCodeRenderers( | ||
| 373 | markdownCodeBg, | ||
| 374 | markdownCodeText, | ||
| 375 | markdownCodeBg, | ||
| 376 | markdownCodeText, | ||
| 377 | ), | ||
| 378 | }, | ||
| 379 | }; | ||
| 380 | }, [ | ||
| 381 | blockquoteBg, | ||
| 382 | blockquoteBorder, | ||
| 383 | bodyColor, | ||
| 384 | codeBg, | ||
| 385 | codeText, | ||
| 386 | hrColor, | ||
| 387 | linkColor, | ||
| 388 | strongColor, | ||
| 389 | userBodyColor, | ||
| 390 | userCodeBg, | ||
| 391 | userCodeText, | ||
| 392 | userFenceBg, | ||
| 393 | userFenceText, | ||
| 394 | ]); | ||
| 395 | } | ||
| 396 | |||
| 397 | function renderFeedEntry( | ||
| 398 | info: { item: ThreadFeedEntry; index: number }, | ||
| 399 | props: Pick<ThreadFeedProps, "bearerToken" | "httpBaseUrl"> & { | ||
| 400 | readonly copiedRowId: string | null; | ||
| 401 | readonly expandedWorkGroups: Record<string, boolean>; | ||
| 402 | readonly onCopyWorkRow: (rowId: string, value: string) => void; | ||
| 403 | readonly onToggleWorkGroup: (groupId: string) => void; | ||
| 404 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; | ||
| 405 | readonly iconSubtleColor: string | import("react-native").ColorValue; | ||
| 406 | readonly userBubbleColor: string | import("react-native").ColorValue; | ||
| 407 | readonly markdownStyles: MarkdownStyleSets; | ||
| 408 | readonly reviewCommentColors: ReviewCommentColors; | ||
| 409 | readonly reviewCommentBubbleWidth: number; | ||
| 410 | }, | ||
| 411 | ) { | ||
| 412 | const entry = info.item; | ||
| 413 | const { markdownStyles, iconSubtleColor, userBubbleColor } = props; | ||
| 414 | |||
| 415 | if (entry.type === "message") { | ||
| 416 | const { message } = entry; | ||
| 417 | const isUser = message.role === "user"; | ||
| 418 | const styles = isUser ? markdownStyles.user : markdownStyles.assistant; | ||
| 419 | const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`; | ||
| 420 | const attachments = message.attachments ?? []; | ||
| 421 | const hasReviewCommentContext = message.text.includes("<review_comment"); | ||
| 422 | |||
| 423 | if (isUser) { | ||
| 424 | return ( | ||
| 425 | <View className="mb-5 items-end"> | ||
| 426 | <View | ||
| 427 | className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5" | ||
| 428 | style={{ | ||
| 429 | backgroundColor: userBubbleColor, | ||
| 430 | ...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null), | ||
| 431 | }} | ||
| 432 | > | ||
| 433 | {message.text.trim().length > 0 ? ( | ||
| 434 | <UserMessageContent | ||
| 435 | text={message.text} | ||
| 436 | markdownStyles={styles} | ||
| 437 | reviewCommentColors={props.reviewCommentColors} | ||
| 438 | /> | ||
| 439 | ) : null} | ||
| 440 | {attachments.map((attachment) => { | ||
| 441 | const uri = messageImageUrl(props.httpBaseUrl, attachment.id); | ||
| 442 | if (!uri) { | ||
| 443 | return null; | ||
| 444 | } | ||
| 445 | const headers = props.bearerToken | ||
| 446 | ? { Authorization: `Bearer ${props.bearerToken}` } | ||
| 447 | : undefined; | ||
| 448 | |||
| 449 | return ( | ||
| 450 | <TouchableOpacity | ||
| 451 | key={attachment.id} | ||
| 452 | activeOpacity={0.7} | ||
| 453 | onPress={() => props.onPressImage(uri, headers)} | ||
| 454 | > | ||
| 455 | <Image | ||
| 456 | source={{ uri, ...(headers ? { headers } : {}) }} | ||
| 457 | className="aspect-[1.3] w-full rounded-[14px] bg-white/15" | ||
| 458 | resizeMode="cover" | ||
| 459 | /> | ||
| 460 | </TouchableOpacity> | ||
| 461 | ); | ||
| 462 | })} | ||
| 463 | </View> | ||
| 464 | <Text className="mt-1.5 px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> | ||
| 465 | {timestampLabel} | ||
| 466 | </Text> | ||
| 467 | </View> | ||
| 468 | ); | ||
| 469 | } | ||
| 470 | |||
| 471 | // Skip empty assistant messages (no text, no attachments) — they would | ||
| 472 | // render as an orphaned timestamp and break adjacent activity-group merging. | ||
| 473 | if (message.text.trim().length === 0 && attachments.length === 0) { | ||
| 474 | return null; | ||
| 475 | } | ||
| 476 | |||
| 477 | return ( | ||
| 478 | <View className="mb-5 px-1"> | ||
| 479 | {message.text.trim().length > 0 ? ( | ||
| 480 | <Markdown | ||
| 481 | options={{ gfm: true }} | ||
| 482 | renderers={styles.renderers} | ||
| 483 | styles={styles.styles} | ||
| 484 | theme={styles.theme} | ||
| 485 | > | ||
| 486 | {message.text} | ||
| 487 | </Markdown> | ||
| 488 | ) : null} | ||
| 489 | {attachments.map((attachment) => { | ||
| 490 | const uri = messageImageUrl(props.httpBaseUrl, attachment.id); | ||
| 491 | if (!uri) { | ||
| 492 | return null; | ||
| 493 | } | ||
| 494 | const headers = props.bearerToken | ||
| 495 | ? { Authorization: `Bearer ${props.bearerToken}` } | ||
| 496 | : undefined; | ||
| 497 | |||
| 498 | return ( | ||
| 499 | <TouchableOpacity | ||
| 500 | key={attachment.id} | ||
| 501 | activeOpacity={0.7} | ||
| 502 | className="mt-1.5" | ||
| 503 | onPress={() => props.onPressImage(uri, headers)} | ||
| 504 | > | ||
| 505 | <Image | ||
| 506 | source={{ uri, ...(headers ? { headers } : {}) }} | ||
| 507 | className="aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800" | ||
| 508 | resizeMode="cover" | ||
| 509 | /> | ||
| 510 | </TouchableOpacity> | ||
| 511 | ); | ||
| 512 | })} | ||
| 513 | <Text className="mt-1.5 font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> | ||
| 514 | {timestampLabel} | ||
| 515 | </Text> | ||
| 516 | </View> | ||
| 517 | ); | ||
| 518 | } | ||
| 519 | |||
| 520 | if (entry.type === "queued-message") { | ||
| 521 | return ( | ||
| 522 | <View className="mb-5 items-end"> | ||
| 523 | <View | ||
| 524 | className="max-w-[85%] gap-2 rounded-[22px] rounded-br-[6px] px-3.5 py-2.5 opacity-60" | ||
| 525 | style={{ backgroundColor: userBubbleColor }} | ||
| 526 | > | ||
| 527 | <Text className="font-sans text-[15px] leading-[22px] text-white"> | ||
| 528 | {entry.queuedMessage.text} | ||
| 529 | </Text> | ||
| 530 | {entry.queuedMessage.attachments.length > 0 ? ( | ||
| 531 | <Text className="font-t3-medium text-xs text-white/75"> | ||
| 532 | {entry.queuedMessage.attachments.length} image | ||
| 533 | {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached | ||
| 534 | </Text> | ||
| 535 | ) : null} | ||
| 536 | </View> | ||
| 537 | <Text className="mt-1.5 px-1 text-right font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> | ||
| 538 | {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`} | ||
| 539 | </Text> | ||
| 540 | </View> | ||
| 541 | ); | ||
| 542 | } | ||
| 543 | |||
| 544 | const rows = buildActivityRows(entry.activities); | ||
| 545 | const isExpanded = props.expandedWorkGroups[entry.id] ?? false; | ||
| 546 | const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | ||
| 547 | const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; | ||
| 548 | const hiddenCount = rows.length - visibleRows.length; | ||
| 549 | const showHeader = hasOverflow; | ||
| 550 | |||
| 551 | return ( | ||
| 552 | <View className="mb-3 rounded-[20px] border border-neutral-200/80 bg-neutral-50/85 px-3 py-2 dark:border-white/[0.06] dark:bg-white/[0.025]"> | ||
| 553 | {showHeader ? ( | ||
| 554 | <View className="mb-1.5 flex-row items-center justify-between gap-3 px-0.5"> | ||
| 555 | <Text className="font-t3-bold text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500"> | ||
| 556 | Tool calls ({rows.length}) | ||
| 557 | </Text> | ||
| 558 | <Pressable onPress={() => props.onToggleWorkGroup(entry.id)}> | ||
| 559 | <Text className="font-t3-medium text-[10px] uppercase tracking-[0.8px] text-neutral-500 dark:text-neutral-500"> | ||
| 560 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | ||
| 561 | </Text> | ||
| 562 | </Pressable> | ||
| 563 | </View> | ||
| 564 | ) : null} | ||
| 565 | {visibleRows.map((row, index) => ( | ||
| 566 | <View | ||
| 567 | key={row.id} | ||
| 568 | className={cn( | ||
| 569 | "flex-row items-center gap-2 rounded-lg px-1 py-1", | ||
| 570 | index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", | ||
| 571 | )} | ||
| 572 | > | ||
| 573 | <View className="items-center justify-center pt-0.5"> | ||
| 574 | <SymbolView name="terminal" size={13} tintColor={iconSubtleColor} type="monochrome" /> | ||
| 575 | </View> | ||
| 576 | <ScrollView | ||
| 577 | horizontal | ||
| 578 | nestedScrollEnabled | ||
| 579 | directionalLockEnabled | ||
| 580 | showsHorizontalScrollIndicator={false} | ||
| 581 | bounces={false} | ||
| 582 | className="flex-1" | ||
| 583 | contentContainerStyle={{ paddingRight: 12 }} | ||
| 584 | style={{ flex: 1 }} | ||
| 585 | > | ||
| 586 | <Text | ||
| 587 | className="text-[12px] leading-[18px] text-neutral-600 dark:text-neutral-400" | ||
| 588 | onLongPress={() => { | ||
| 589 | const copyValue = row.detail ?? row.summary; | ||
| 590 | props.onCopyWorkRow(row.id, copyValue); | ||
| 591 | }} | ||
| 592 | style={{ | ||
| 593 | fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", | ||
| 594 | }} | ||
| 595 | > | ||
| 596 | {row.detail ? `${row.summary} - ${row.detail}` : row.summary} | ||
| 597 | </Text> | ||
| 598 | </ScrollView> | ||
| 599 | {props.copiedRowId === row.id ? ( | ||
| 600 | <Text className="shrink-0 font-t3-medium text-[10px] uppercase tracking-[0.8px] text-emerald-600 dark:text-emerald-400"> | ||
| 601 | Copied | ||
| 602 | </Text> | ||
| 603 | ) : null} | ||
| 604 | </View> | ||
| 605 | ))} | ||
| 606 | </View> | ||
| 607 | ); | ||
| 608 | } | ||
| 609 | |||
| 610 | function UserMessageContent(props: { | ||
| 611 | readonly text: string; | ||
| 612 | readonly markdownStyles: MarkdownStyleSet; | ||
| 613 | readonly reviewCommentColors: ReviewCommentColors; | ||
| 614 | }) { | ||
| 615 | const segments = parseReviewCommentMessageSegments(props.text); | ||
| 616 | const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); | ||
| 617 | if (!hasReviewComment) { | ||
| 618 | return ( | ||
| 619 | <Markdown | ||
| 620 | options={{ gfm: true }} | ||
| 621 | renderers={props.markdownStyles.renderers} | ||
| 622 | styles={props.markdownStyles.styles} | ||
| 623 | theme={props.markdownStyles.theme} | ||
| 624 | > | ||
| 625 | {props.text} | ||
| 626 | </Markdown> | ||
| 627 | ); | ||
| 628 | } | ||
| 629 | |||
| 630 | return ( | ||
| 631 | <View className="w-full gap-2"> | ||
| 632 | {segments.map((segment) => { | ||
| 633 | if (segment.kind === "review-comment") { | ||
| 634 | return ( | ||
| 635 | <ReviewCommentCard | ||
| 636 | key={segment.comment.id} | ||
| 637 | comment={segment.comment} | ||
| 638 | colors={props.reviewCommentColors} | ||
| 639 | /> | ||
| 640 | ); | ||
| 641 | } | ||
| 642 | |||
| 643 | const text = segment.text.trim(); | ||
| 644 | if (text.length === 0) { | ||
| 645 | return null; | ||
| 646 | } | ||
| 647 | |||
| 648 | return ( | ||
| 649 | <Markdown | ||
| 650 | key={segment.id} | ||
| 651 | options={{ gfm: true }} | ||
| 652 | renderers={props.markdownStyles.renderers} | ||
| 653 | styles={props.markdownStyles.styles} | ||
| 654 | theme={props.markdownStyles.theme} | ||
| 655 | > | ||
| 656 | {text} | ||
| 657 | </Markdown> | ||
| 658 | ); | ||
| 659 | })} | ||
| 660 | </View> | ||
| 661 | ); | ||
| 662 | } | ||
| 663 | |||
| 664 | const ReviewCommentCard = memo(function ReviewCommentCard(props: { | ||
| 665 | readonly comment: ReviewInlineComment; | ||
| 666 | readonly colors: ReviewCommentColors; | ||
| 667 | }) { | ||
| 668 | const colorScheme = useColorScheme(); | ||
| 669 | const appearanceScheme = colorScheme === "light" ? "light" : "dark"; | ||
| 670 | const NativeReviewDiffView = resolveNativeReviewDiffView(); | ||
| 671 | const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]); | ||
| 672 | const parsedDiff = useMemo( | ||
| 673 | () => buildReviewParsedDiff(patch, `thread-review-comment:${props.comment.id}`), | ||
| 674 | [patch, props.comment.id], | ||
| 675 | ); | ||
| 676 | const nativeReviewDiffData = useMemo(() => buildNativeReviewDiffData(parsedDiff), [parsedDiff]); | ||
| 677 | const compactNativeRows = useMemo( | ||
| 678 | () => nativeReviewDiffData.rows.filter((row) => row.kind !== "file"), | ||
| 679 | [nativeReviewDiffData.rows], | ||
| 680 | ); | ||
| 681 | const nativeReviewDiffTheme = useMemo( | ||
| 682 | () => createNativeReviewDiffTheme(appearanceScheme), | ||
| 683 | [appearanceScheme], | ||
| 684 | ); | ||
| 685 | const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); | ||
| 686 | const nativeThemeJson = useMemo( | ||
| 687 | () => JSON.stringify(nativeReviewDiffTheme), | ||
| 688 | [nativeReviewDiffTheme], | ||
| 689 | ); | ||
| 690 | const nativeStyleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []); | ||
| 691 | const nativeDiffHeight = useMemo( | ||
| 692 | () => | ||
| 693 | Math.min( | ||
| 694 | 360, | ||
| 695 | Math.max( | ||
| 696 | 112, | ||
| 697 | compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT + | ||
| 698 | NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin, | ||
| 699 | ), | ||
| 700 | ), | ||
| 701 | [compactNativeRows.length], | ||
| 702 | ); | ||
| 703 | const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0; | ||
| 704 | |||
| 705 | return ( | ||
| 706 | <View | ||
| 707 | className="w-full overflow-hidden rounded-[16px] border" | ||
| 708 | style={{ | ||
| 709 | backgroundColor: props.colors.background, | ||
| 710 | borderColor: props.colors.border, | ||
| 711 | borderCurve: "continuous", | ||
| 712 | }} | ||
| 713 | > | ||
| 714 | <View | ||
| 715 | className="flex-row items-center gap-2 border-b px-3 py-2" | ||
| 716 | style={{ borderColor: props.colors.border }} | ||
| 717 | > | ||
| 718 | <View | ||
| 719 | className="size-6 items-center justify-center rounded-[7px]" | ||
| 720 | style={{ backgroundColor: props.colors.mutedBackground, borderCurve: "continuous" }} | ||
| 721 | > | ||
| 722 | <SymbolView | ||
| 723 | name="doc.text" | ||
| 724 | size={13} | ||
| 725 | tintColor={props.colors.mutedText} | ||
| 726 | type="monochrome" | ||
| 727 | /> | ||
| 728 | </View> | ||
| 729 | <View className="min-w-0 flex-1"> | ||
| 730 | <Text | ||
| 731 | className="font-mono text-[12px] leading-[16px]" | ||
| 732 | numberOfLines={1} | ||
| 733 | style={{ color: props.colors.text }} | ||
| 734 | > | ||
| 735 | {compactFileName(props.comment.filePath)} | ||
| 736 | </Text> | ||
| 737 | </View> | ||
| 738 | </View> | ||
| 739 | {shouldRenderNativeDiff ? ( | ||
| 740 | <View | ||
| 741 | className="border-t" | ||
| 742 | collapsable={false} | ||
| 743 | style={{ | ||
| 744 | backgroundColor: nativeReviewDiffTheme.background, | ||
| 745 | borderColor: props.colors.border, | ||
| 746 | height: nativeDiffHeight, | ||
| 747 | }} | ||
| 748 | > | ||
| 749 | <NativeReviewDiffView | ||
| 750 | collapsable={false} | ||
| 751 | style={StyleSheet.absoluteFill} | ||
| 752 | appearanceScheme={appearanceScheme} | ||
| 753 | contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} | ||
| 754 | rowHeight={NATIVE_REVIEW_DIFF_ROW_HEIGHT} | ||
| 755 | rowsJson={nativeRowsJson} | ||
| 756 | styleJson={nativeStyleJson} | ||
| 757 | themeJson={nativeThemeJson} | ||
| 758 | /> | ||
| 759 | </View> | ||
| 760 | ) : props.comment.diff.trim().length > 0 ? ( | ||
| 761 | <ScrollView | ||
| 762 | horizontal | ||
| 763 | nestedScrollEnabled | ||
| 764 | directionalLockEnabled | ||
| 765 | showsHorizontalScrollIndicator={false} | ||
| 766 | bounces={false} | ||
| 767 | className="border-t" | ||
| 768 | style={{ backgroundColor: props.colors.codeBackground, borderColor: props.colors.border }} | ||
| 769 | contentContainerStyle={{ padding: 10 }} | ||
| 770 | > | ||
| 771 | <NativeText | ||
| 772 | selectable | ||
| 773 | style={{ | ||
| 774 | color: props.colors.text, | ||
| 775 | fontFamily: "ui-monospace", | ||
| 776 | fontSize: 12, | ||
| 777 | lineHeight: 18, | ||
| 778 | }} | ||
| 779 | > | ||
| 780 | {props.comment.diff.trim()} | ||
| 781 | </NativeText> | ||
| 782 | </ScrollView> | ||
| 783 | ) : null} | ||
| 784 | {props.comment.text.length > 0 ? ( | ||
| 785 | <View className="border-t px-3 py-3" style={{ borderColor: props.colors.border }}> | ||
| 786 | <Text | ||
| 787 | selectable | ||
| 788 | className="text-[15px] leading-[21px]" | ||
| 789 | style={{ color: props.colors.text }} | ||
| 790 | > | ||
| 791 | {props.comment.text} | ||
| 792 | </Text> | ||
| 793 | </View> | ||
| 794 | ) : null} | ||
| 795 | </View> | ||
| 796 | ); | ||
| 797 | }); | ||
| 798 | |||
| 799 | function buildReviewCommentPatch(comment: ReviewInlineComment): string { | ||
| 800 | const diff = comment.diff.trim(); | ||
| 801 | if (!diff) { | ||
| 802 | return ""; | ||
| 803 | } | ||
| 804 | |||
| 805 | if (diff.startsWith("diff --git ")) { | ||
| 806 | return diff; | ||
| 807 | } | ||
| 808 | |||
| 809 | const normalizedPath = comment.filePath.replaceAll("\\", "/"); | ||
| 810 | return [ | ||
| 811 | `diff --git a/${normalizedPath} b/${normalizedPath}`, | ||
| 812 | `--- a/${normalizedPath}`, | ||
| 813 | `+++ b/${normalizedPath}`, | ||
| 814 | diff, | ||
| 815 | ].join("\n"); | ||
| 816 | } | ||
| 817 | |||
| 818 | function compactFileName(filePath: string): string { | ||
| 819 | const normalized = filePath.replaceAll("\\", "/"); | ||
| 820 | const lastSlashIndex = normalized.lastIndexOf("/"); | ||
| 821 | return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; | ||
| 822 | } | ||
| 823 | |||
| 824 | const IOS_NAV_BAR_HEIGHT = 44; | ||
| 825 | |||
| 826 | export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ||
| 827 | const listRef = useRef<LegendListRef>(null); | ||
| 828 | const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 829 | const { width: viewportWidth } = useWindowDimensions(); | ||
| 830 | const [copiedRowId, setCopiedRowId] = useState<string | null>(null); | ||
| 831 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | ||
| 832 | const [expandedImage, setExpandedImage] = useState<{ | ||
| 833 | uri: string; | ||
| 834 | headers?: Record<string, string>; | ||
| 835 | } | null>(null); | ||
| 836 | const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; | ||
| 837 | const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); | ||
| 838 | const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); | ||
| 839 | const insets = useSafeAreaInsets(); | ||
| 840 | const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; | ||
| 841 | const bottomContentInset = props.contentBottomInset ?? 18; | ||
| 842 | |||
| 843 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | ||
| 844 | const userBubbleColor = useThemeColor("--color-user-bubble"); | ||
| 845 | const markdownStyles = useMarkdownStyles(); | ||
| 846 | const reviewCommentColors = useReviewCommentColors(); | ||
| 847 | |||
| 848 | useEffect(() => { | ||
| 849 | setCopiedRowId(null); | ||
| 850 | setExpandedWorkGroups({}); | ||
| 851 | }, [props.threadId]); | ||
| 852 | |||
| 853 | useEffect(() => { | ||
| 854 | return () => { | ||
| 855 | if (copyFeedbackTimeoutRef.current) { | ||
| 856 | clearTimeout(copyFeedbackTimeoutRef.current); | ||
| 857 | } | ||
| 858 | }; | ||
| 859 | }, []); | ||
| 860 | |||
| 861 | const onCopyWorkRow = useCallback((rowId: string, value: string) => { | ||
| 862 | void Clipboard.setStringAsync(value); | ||
| 863 | void Haptics.selectionAsync(); | ||
| 864 | setCopiedRowId(rowId); | ||
| 865 | if (copyFeedbackTimeoutRef.current) { | ||
| 866 | clearTimeout(copyFeedbackTimeoutRef.current); | ||
| 867 | } | ||
| 868 | copyFeedbackTimeoutRef.current = setTimeout(() => { | ||
| 869 | setCopiedRowId((current) => (current === rowId ? null : current)); | ||
| 870 | copyFeedbackTimeoutRef.current = null; | ||
| 871 | }, 1200); | ||
| 872 | }, []); | ||
| 873 | |||
| 874 | const onToggleWorkGroup = useCallback((groupId: string) => { | ||
| 875 | setExpandedWorkGroups((current) => ({ | ||
| 876 | ...current, | ||
| 877 | [groupId]: !(current[groupId] ?? false), | ||
| 878 | })); | ||
| 879 | }, []); | ||
| 880 | |||
| 881 | const onPressImage = useCallback((uri: string, headers?: Record<string, string>) => { | ||
| 882 | setExpandedImage({ uri, headers }); | ||
| 883 | }, []); | ||
| 884 | |||
| 885 | const renderItem = useCallback( | ||
| 886 | (info: { item: ThreadFeedEntry; index: number }) => | ||
| 887 | renderFeedEntry(info, { | ||
| 888 | bearerToken: props.bearerToken, | ||
| 889 | copiedRowId, | ||
| 890 | httpBaseUrl: props.httpBaseUrl, | ||
| 891 | expandedWorkGroups, | ||
| 892 | onCopyWorkRow, | ||
| 893 | onToggleWorkGroup, | ||
| 894 | onPressImage, | ||
| 895 | iconSubtleColor, | ||
| 896 | userBubbleColor, | ||
| 897 | markdownStyles, | ||
| 898 | reviewCommentColors, | ||
| 899 | reviewCommentBubbleWidth, | ||
| 900 | }), | ||
| 901 | [ | ||
| 902 | copiedRowId, | ||
| 903 | expandedWorkGroups, | ||
| 904 | iconSubtleColor, | ||
| 905 | userBubbleColor, | ||
| 906 | markdownStyles, | ||
| 907 | reviewCommentColors, | ||
| 908 | reviewCommentBubbleWidth, | ||
| 909 | onCopyWorkRow, | ||
| 910 | onPressImage, | ||
| 911 | onToggleWorkGroup, | ||
| 912 | props.bearerToken, | ||
| 913 | props.httpBaseUrl, | ||
| 914 | ], | ||
| 915 | ); | ||
| 916 | |||
| 917 | if (props.feed.length === 0) { | ||
| 918 | return ( | ||
| 919 | <ScrollView | ||
| 920 | style={{ flex: 1 }} | ||
| 921 | contentInsetAdjustmentBehavior="never" | ||
| 922 | contentInset={{ top: topContentInset, bottom: bottomContentInset }} | ||
| 923 | contentOffset={{ x: 0, y: -topContentInset }} | ||
| 924 | scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }} | ||
| 925 | contentContainerStyle={{ | ||
| 926 | flexGrow: 1, | ||
| 927 | paddingHorizontal: horizontalPadding, | ||
| 928 | }} | ||
| 929 | > | ||
| 930 | <EmptyState | ||
| 931 | title="No conversation yet" | ||
| 932 | detail="Ask the agent to inspect the repo, run a command, or continue the active thread." | ||
| 933 | /> | ||
| 934 | </ScrollView> | ||
| 935 | ); | ||
| 936 | } | ||
| 937 | |||
| 938 | return ( | ||
| 939 | <> | ||
| 940 | <KeyboardAvoidingLegendList | ||
| 941 | ref={listRef} | ||
| 942 | key={props.threadId} | ||
| 943 | style={{ flex: 1 }} | ||
| 944 | alignItemsAtEnd | ||
| 945 | contentInsetAdjustmentBehavior="never" | ||
| 946 | contentInset={{ top: topContentInset, bottom: bottomContentInset }} | ||
| 947 | scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }} | ||
| 948 | data={props.feed as ThreadFeedEntry[]} | ||
| 949 | renderItem={renderItem} | ||
| 950 | keyExtractor={(entry) => `${entry.type}:${entry.id}`} | ||
| 951 | getItemType={(entry) => | ||
| 952 | entry.type === "message" ? `message:${entry.message.role}` : entry.type | ||
| 953 | } | ||
| 954 | keyboardShouldPersistTaps="handled" | ||
| 955 | estimatedItemSize={180} | ||
| 956 | initialScrollAtEnd | ||
| 957 | maintainScrollAtEnd={{ | ||
| 958 | on: { layout: true, itemLayout: true, dataChange: true }, | ||
| 959 | }} | ||
| 960 | maintainScrollAtEndThreshold={0.1} | ||
| 961 | refreshing={props.refreshing ?? false} | ||
| 962 | onRefresh={props.onRefresh} | ||
| 963 | safeAreaInsetBottom={insets.bottom} | ||
| 964 | contentContainerStyle={{ | ||
| 965 | paddingTop: 12, | ||
| 966 | paddingHorizontal: horizontalPadding, | ||
| 967 | }} | ||
| 968 | /> | ||
| 969 | |||
| 970 | <ImageViewing | ||
| 971 | images={ | ||
| 972 | expandedImage | ||
| 973 | ? [ | ||
| 974 | { | ||
| 975 | uri: expandedImage.uri, | ||
| 976 | headers: expandedImage.headers, | ||
| 977 | }, | ||
| 978 | ] | ||
| 979 | : [] | ||
| 980 | } | ||
| 981 | imageIndex={0} | ||
| 982 | visible={expandedImage !== null} | ||
| 983 | onRequestClose={() => setExpandedImage(null)} | ||
| 984 | swipeToCloseEnabled | ||
| 985 | doubleTapToZoomEnabled | ||
| 986 | /> | ||
| 987 | </> | ||
| 988 | ); | ||
| 989 | }); | ||
apps/mobile/src/features/threads/ThreadGitControls.tsx created+277| ... | @@ -0,0 +1,277 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | GitRunStackedActionResult, | ||
| 4 | ProjectScript, | ||
| 5 | ThreadId, | ||
| 6 | VcsStatusResult, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { | ||
| 9 | type GitActionRequestInput, | ||
| 10 | requiresDefaultBranchConfirmation, | ||
| 11 | resolveQuickAction, | ||
| 12 | } from "@t3tools/client-runtime"; | ||
| 13 | import { useLocalSearchParams, useRouter } from "expo-router"; | ||
| 14 | import Stack from "expo-router/stack"; | ||
| 15 | import { useCallback, useMemo } from "react"; | ||
| 16 | import { Alert, Linking } from "react-native"; | ||
| 17 | import { buildThreadReviewRoutePath } from "../../lib/routes"; | ||
| 18 | import { | ||
| 19 | basename, | ||
| 20 | getTerminalStatusLabel, | ||
| 21 | projectScriptMenuIcon, | ||
| 22 | projectScriptMenuLabel, | ||
| 23 | type TerminalMenuSession, | ||
| 24 | } from "../terminal/terminalMenu"; | ||
| 25 | |||
| 26 | function truncateMiddle(value: string, maxLength: number): string { | ||
| 27 | if (value.length <= maxLength) { | ||
| 28 | return value; | ||
| 29 | } | ||
| 30 | |||
| 31 | const headLength = Math.ceil((maxLength - 1) / 2); | ||
| 32 | const tailLength = Math.floor((maxLength - 1) / 2); | ||
| 33 | return `${value.slice(0, headLength)}…${value.slice(value.length - tailLength)}`; | ||
| 34 | } | ||
| 35 | |||
| 36 | function compactMenuBranchLabel(branch: string): string { | ||
| 37 | return truncateMiddle(branch, 24); | ||
| 38 | } | ||
| 39 | |||
| 40 | function compactMenuStatus(gitStatus: VcsStatusResult | null): string { | ||
| 41 | if (!gitStatus) { | ||
| 42 | return "Checking status"; | ||
| 43 | } | ||
| 44 | if (!gitStatus.isRepo) { | ||
| 45 | return "Not a repo"; | ||
| 46 | } | ||
| 47 | |||
| 48 | const parts: string[] = []; | ||
| 49 | if (gitStatus.hasWorkingTreeChanges) { | ||
| 50 | parts.push(`${gitStatus.workingTree.files.length} changed`); | ||
| 51 | } else if (gitStatus.aheadCount === 0 && gitStatus.behindCount === 0) { | ||
| 52 | parts.push("Clean"); | ||
| 53 | } | ||
| 54 | if (gitStatus.aheadCount > 0) { | ||
| 55 | parts.push(`${gitStatus.aheadCount} ahead`); | ||
| 56 | } | ||
| 57 | if (gitStatus.behindCount > 0) { | ||
| 58 | parts.push(`${gitStatus.behindCount} behind`); | ||
| 59 | } | ||
| 60 | if (gitStatus.pr?.state === "open") { | ||
| 61 | parts.push(`PR #${gitStatus.pr.number}`); | ||
| 62 | } | ||
| 63 | |||
| 64 | return parts.join(" · "); | ||
| 65 | } | ||
| 66 | |||
| 67 | export function ThreadGitControls(props: { | ||
| 68 | readonly currentBranch: string | null; | ||
| 69 | readonly gitStatus: VcsStatusResult | null; | ||
| 70 | readonly gitOperationLabel: string | null; | ||
| 71 | readonly canOpenTerminal: boolean; | ||
| 72 | readonly projectScripts: ReadonlyArray<ProjectScript>; | ||
| 73 | readonly terminalSessions: ReadonlyArray<TerminalMenuSession>; | ||
| 74 | readonly onOpenTerminal: (terminalId?: string | null) => void; | ||
| 75 | readonly onOpenNewTerminal: () => void; | ||
| 76 | readonly onRunProjectScript: (script: ProjectScript) => Promise<void>; | ||
| 77 | readonly onPull: () => Promise<void>; | ||
| 78 | readonly onRunAction: (input: GitActionRequestInput) => Promise<GitRunStackedActionResult | null>; | ||
| 79 | }) { | ||
| 80 | const router = useRouter(); | ||
| 81 | const { environmentId, threadId } = useLocalSearchParams<{ | ||
| 82 | environmentId: EnvironmentId; | ||
| 83 | threadId: ThreadId; | ||
| 84 | }>(); | ||
| 85 | const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; | ||
| 86 | |||
| 87 | const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD"; | ||
| 88 | const busy = gitOperationLabel !== null; | ||
| 89 | const isRepo = gitStatus?.isRepo ?? true; | ||
| 90 | const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; | ||
| 91 | const isDefaultRef = gitStatus?.isDefaultRef ?? false; | ||
| 92 | |||
| 93 | const quickAction = useMemo( | ||
| 94 | () => | ||
| 95 | isRepo | ||
| 96 | ? resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote) | ||
| 97 | : { | ||
| 98 | label: "Git unavailable", | ||
| 99 | disabled: true, | ||
| 100 | kind: "show_hint" as const, | ||
| 101 | hint: "This workspace is not a git repository.", | ||
| 102 | }, | ||
| 103 | [busy, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo], | ||
| 104 | ); | ||
| 105 | |||
| 106 | const quickActionHint = quickAction.disabled | ||
| 107 | ? (quickAction.hint ?? "This action is unavailable.") | ||
| 108 | : null; | ||
| 109 | |||
| 110 | const quickActionIcon = (() => { | ||
| 111 | if (quickAction.kind === "run_pull") return "arrow.down.circle"; | ||
| 112 | if (quickAction.kind === "open_pr") return "arrow.up.right.circle"; | ||
| 113 | if (quickAction.kind === "run_action") { | ||
| 114 | if (quickAction.action === "commit") return "checkmark.circle"; | ||
| 115 | if (quickAction.action === "push" || quickAction.action === "commit_push") | ||
| 116 | return "arrow.up.circle"; | ||
| 117 | } | ||
| 118 | return "arrow.up.right.circle"; | ||
| 119 | })(); | ||
| 120 | |||
| 121 | const openExistingPr = useCallback(async () => { | ||
| 122 | const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null; | ||
| 123 | if (!prUrl) { | ||
| 124 | Alert.alert("No open PR", "This branch does not have an open pull request."); | ||
| 125 | return; | ||
| 126 | } | ||
| 127 | try { | ||
| 128 | await Linking.openURL(prUrl); | ||
| 129 | } catch (error) { | ||
| 130 | Alert.alert( | ||
| 131 | "Unable to open PR", | ||
| 132 | error instanceof Error ? error.message : "An error occurred.", | ||
| 133 | ); | ||
| 134 | } | ||
| 135 | }, [gitStatus]); | ||
| 136 | |||
| 137 | const runActionWithPrompt = useCallback( | ||
| 138 | async (input: GitActionRequestInput) => { | ||
| 139 | const confirmableAction = | ||
| 140 | input.action === "push" || | ||
| 141 | input.action === "create_pr" || | ||
| 142 | input.action === "commit_push" || | ||
| 143 | input.action === "commit_push_pr" | ||
| 144 | ? input.action | ||
| 145 | : null; | ||
| 146 | const branchName = gitStatus?.refName; | ||
| 147 | if ( | ||
| 148 | branchName && | ||
| 149 | confirmableAction && | ||
| 150 | !input.featureBranch && | ||
| 151 | requiresDefaultBranchConfirmation(input.action, isDefaultRef) | ||
| 152 | ) { | ||
| 153 | router.push({ | ||
| 154 | pathname: "/threads/[environmentId]/[threadId]/git-confirm", | ||
| 155 | params: { | ||
| 156 | environmentId, | ||
| 157 | threadId, | ||
| 158 | confirmAction: confirmableAction, | ||
| 159 | branchName, | ||
| 160 | includesCommit: String( | ||
| 161 | input.action === "commit_push" || input.action === "commit_push_pr", | ||
| 162 | ), | ||
| 163 | }, | ||
| 164 | }); | ||
| 165 | return; | ||
| 166 | } | ||
| 167 | |||
| 168 | await onRunAction(input); | ||
| 169 | }, | ||
| 170 | [environmentId, gitStatus, isDefaultRef, onRunAction, router, threadId], | ||
| 171 | ); | ||
| 172 | |||
| 173 | const runQuickAction = useCallback(async () => { | ||
| 174 | if (quickAction.kind === "open_pr") { | ||
| 175 | await openExistingPr(); | ||
| 176 | return; | ||
| 177 | } | ||
| 178 | if (quickAction.kind === "run_pull") { | ||
| 179 | await onPull(); | ||
| 180 | return; | ||
| 181 | } | ||
| 182 | if (quickAction.kind === "run_action" && quickAction.action) { | ||
| 183 | await runActionWithPrompt({ action: quickAction.action }); | ||
| 184 | } | ||
| 185 | }, [onPull, openExistingPr, quickAction, runActionWithPrompt]); | ||
| 186 | |||
| 187 | return ( | ||
| 188 | <Stack.Toolbar placement="right"> | ||
| 189 | <Stack.Toolbar.Menu icon="terminal" disabled={!props.canOpenTerminal} separateBackground> | ||
| 190 | {props.projectScripts.length > 0 ? ( | ||
| 191 | props.projectScripts.map((script) => ( | ||
| 192 | <Stack.Toolbar.MenuAction | ||
| 193 | key={script.id} | ||
| 194 | icon={projectScriptMenuIcon(script.icon)} | ||
| 195 | onPress={() => void props.onRunProjectScript(script)} | ||
| 196 | subtitle={script.command} | ||
| 197 | > | ||
| 198 | <Stack.Toolbar.Label>{projectScriptMenuLabel(script)}</Stack.Toolbar.Label> | ||
| 199 | </Stack.Toolbar.MenuAction> | ||
| 200 | )) | ||
| 201 | ) : ( | ||
| 202 | <Stack.Toolbar.MenuAction | ||
| 203 | icon="play" | ||
| 204 | disabled | ||
| 205 | onPress={() => {}} | ||
| 206 | subtitle="This project has no saved scripts yet" | ||
| 207 | > | ||
| 208 | <Stack.Toolbar.Label>No project scripts</Stack.Toolbar.Label> | ||
| 209 | </Stack.Toolbar.MenuAction> | ||
| 210 | )} | ||
| 211 | {props.terminalSessions.map((session) => ( | ||
| 212 | <Stack.Toolbar.MenuAction | ||
| 213 | key={session.terminalId} | ||
| 214 | icon="terminal" | ||
| 215 | onPress={() => props.onOpenTerminal(session.terminalId)} | ||
| 216 | subtitle={[ | ||
| 217 | getTerminalStatusLabel({ | ||
| 218 | status: session.status, | ||
| 219 | hasRunningSubprocess: session.hasRunningSubprocess, | ||
| 220 | }), | ||
| 221 | basename(session.cwd), | ||
| 222 | ] | ||
| 223 | .filter(Boolean) | ||
| 224 | .join(" · ")} | ||
| 225 | > | ||
| 226 | <Stack.Toolbar.Label>{session.displayLabel}</Stack.Toolbar.Label> | ||
| 227 | </Stack.Toolbar.MenuAction> | ||
| 228 | ))} | ||
| 229 | <Stack.Toolbar.MenuAction | ||
| 230 | icon="plus" | ||
| 231 | onPress={props.onOpenNewTerminal} | ||
| 232 | subtitle="Start another shell for this thread" | ||
| 233 | > | ||
| 234 | <Stack.Toolbar.Label>Open new terminal</Stack.Toolbar.Label> | ||
| 235 | </Stack.Toolbar.MenuAction> | ||
| 236 | </Stack.Toolbar.Menu> | ||
| 237 | <Stack.Toolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up"> | ||
| 238 | <Stack.Toolbar.MenuAction | ||
| 239 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 240 | disabled | ||
| 241 | onPress={() => {}} | ||
| 242 | subtitle={compactMenuStatus(gitStatus)} | ||
| 243 | > | ||
| 244 | <Stack.Toolbar.Label>{compactMenuBranchLabel(currentBranchLabel)}</Stack.Toolbar.Label> | ||
| 245 | </Stack.Toolbar.MenuAction> | ||
| 246 | <Stack.Toolbar.MenuAction | ||
| 247 | icon={quickActionIcon} | ||
| 248 | disabled={quickAction.disabled} | ||
| 249 | onPress={() => void runQuickAction()} | ||
| 250 | subtitle={quickActionHint ?? undefined} | ||
| 251 | > | ||
| 252 | <Stack.Toolbar.Label>{quickAction.label}</Stack.Toolbar.Label> | ||
| 253 | </Stack.Toolbar.MenuAction> | ||
| 254 | <Stack.Toolbar.MenuAction | ||
| 255 | icon="text.bubble" | ||
| 256 | disabled={!isRepo} | ||
| 257 | onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))} | ||
| 258 | subtitle="Turn diffs and worktree changes" | ||
| 259 | > | ||
| 260 | <Stack.Toolbar.Label>Review changes</Stack.Toolbar.Label> | ||
| 261 | </Stack.Toolbar.MenuAction> | ||
| 262 | <Stack.Toolbar.MenuAction | ||
| 263 | icon="ellipsis.circle" | ||
| 264 | onPress={() => | ||
| 265 | router.push({ | ||
| 266 | pathname: "/threads/[environmentId]/[threadId]/git", | ||
| 267 | params: { environmentId, threadId }, | ||
| 268 | }) | ||
| 269 | } | ||
| 270 | subtitle="Commit, files, branches" | ||
| 271 | > | ||
| 272 | <Stack.Toolbar.Label>More</Stack.Toolbar.Label> | ||
| 273 | </Stack.Toolbar.MenuAction> | ||
| 274 | </Stack.Toolbar.Menu> | ||
| 275 | </Stack.Toolbar> | ||
| 276 | ); | ||
| 277 | } | ||
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx created+261| ... | @@ -0,0 +1,261 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 3 | import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; | ||
| 4 | import * as Arr from "effect/Array"; | ||
| 5 | import * as Order from "effect/Order"; | ||
| 6 | import { Gesture, GestureDetector } from "react-native-gesture-handler"; | ||
| 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 8 | import Animated, { | ||
| 9 | runOnJS, | ||
| 10 | useAnimatedStyle, | ||
| 11 | useSharedValue, | ||
| 12 | withTiming, | ||
| 13 | } from "react-native-reanimated"; | ||
| 14 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 15 | |||
| 16 | import { AppText as Text } from "../../components/AppText"; | ||
| 17 | import { StatusPill } from "../../components/StatusPill"; | ||
| 18 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | ||
| 19 | import { scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 20 | import { relativeTime } from "../../lib/time"; | ||
| 21 | import { threadStatusTone } from "./threadPresentation"; | ||
| 22 | import { | ||
| 23 | EnvironmentScopedProjectShell, | ||
| 24 | EnvironmentScopedThreadShell, | ||
| 25 | } from "@t3tools/client-runtime"; | ||
| 26 | |||
| 27 | const threadActivityOrder = Order.mapInput( | ||
| 28 | Order.Struct({ | ||
| 29 | activityAt: Order.flip(Order.Number), | ||
| 30 | title: Order.String, | ||
| 31 | }), | ||
| 32 | (thread: EnvironmentScopedThreadShell) => ({ | ||
| 33 | activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), | ||
| 34 | title: thread.title, | ||
| 35 | }), | ||
| 36 | ); | ||
| 37 | |||
| 38 | export function ThreadNavigationDrawer(props: { | ||
| 39 | readonly visible: boolean; | ||
| 40 | readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>; | ||
| 41 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | ||
| 42 | readonly selectedThreadKey: string | null; | ||
| 43 | readonly onClose: () => void; | ||
| 44 | readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; | ||
| 45 | readonly onStartNewTask: () => void; | ||
| 46 | }) { | ||
| 47 | const insets = useSafeAreaInsets(); | ||
| 48 | const { width } = useWindowDimensions(); | ||
| 49 | const drawerWidth = Math.min(width * 0.84, 360); | ||
| 50 | const [mounted, setMounted] = useState(props.visible); | ||
| 51 | const translateX = useSharedValue(-drawerWidth); | ||
| 52 | const overlayOpacity = useSharedValue(0); | ||
| 53 | |||
| 54 | const backdropColor = useThemeColor("--color-backdrop"); | ||
| 55 | const drawerBg = useThemeColor("--color-drawer"); | ||
| 56 | const drawerShadow = useThemeColor("--color-drawer-shadow"); | ||
| 57 | const primaryForeground = useThemeColor("--color-primary-foreground"); | ||
| 58 | const borderSubtleColor = useThemeColor("--color-border-subtle"); | ||
| 59 | |||
| 60 | const repositoryGroups = useMemo( | ||
| 61 | () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), | ||
| 62 | [props.projects, props.threads], | ||
| 63 | ); | ||
| 64 | const groupedThreads = useMemo( | ||
| 65 | () => | ||
| 66 | repositoryGroups.map((group) => { | ||
| 67 | const threads: EnvironmentScopedThreadShell[] = []; | ||
| 68 | for (const projectGroup of group.projects) { | ||
| 69 | threads.push(...projectGroup.threads); | ||
| 70 | } | ||
| 71 | return { | ||
| 72 | key: group.key, | ||
| 73 | title: group.projects[0]?.project.title ?? group.title, | ||
| 74 | threads: Arr.sort(threads, threadActivityOrder), | ||
| 75 | }; | ||
| 76 | }), | ||
| 77 | [repositoryGroups], | ||
| 78 | ); | ||
| 79 | |||
| 80 | useEffect(() => { | ||
| 81 | if (props.visible) { | ||
| 82 | setMounted(true); | ||
| 83 | translateX.value = withTiming(0, { duration: 240 }); | ||
| 84 | overlayOpacity.value = withTiming(1, { duration: 220 }); | ||
| 85 | return; | ||
| 86 | } | ||
| 87 | |||
| 88 | overlayOpacity.value = withTiming(0, { duration: 180 }); | ||
| 89 | translateX.value = withTiming(-drawerWidth, { duration: 220 }, (finished) => { | ||
| 90 | if (finished) { | ||
| 91 | runOnJS(setMounted)(false); | ||
| 92 | } | ||
| 93 | }); | ||
| 94 | }, [drawerWidth, overlayOpacity, props.visible, translateX]); | ||
| 95 | |||
| 96 | const closeDrawer = useCallback(() => { | ||
| 97 | props.onClose(); | ||
| 98 | }, [props]); | ||
| 99 | |||
| 100 | const panGesture = useMemo( | ||
| 101 | () => | ||
| 102 | Gesture.Pan() | ||
| 103 | .activeOffsetX([-12, 12]) | ||
| 104 | .failOffsetY([-24, 24]) | ||
| 105 | .onUpdate((event) => { | ||
| 106 | translateX.value = Math.min(0, event.translationX); | ||
| 107 | }) | ||
| 108 | .onEnd((event) => { | ||
| 109 | const shouldClose = event.translationX < -drawerWidth * 0.2 || event.velocityX < -500; | ||
| 110 | if (shouldClose) { | ||
| 111 | runOnJS(closeDrawer)(); | ||
| 112 | return; | ||
| 113 | } | ||
| 114 | |||
| 115 | translateX.value = withTiming(0, { duration: 180 }); | ||
| 116 | }), | ||
| 117 | [closeDrawer, drawerWidth, translateX], | ||
| 118 | ); | ||
| 119 | |||
| 120 | const drawerStyle = useAnimatedStyle(() => ({ | ||
| 121 | transform: [{ translateX: translateX.value }], | ||
| 122 | })); | ||
| 123 | |||
| 124 | const backdropStyle = useAnimatedStyle(() => ({ | ||
| 125 | opacity: overlayOpacity.value, | ||
| 126 | })); | ||
| 127 | |||
| 128 | if (!mounted) { | ||
| 129 | return null; | ||
| 130 | } | ||
| 131 | |||
| 132 | return ( | ||
| 133 | <Modal transparent visible={mounted} onRequestClose={props.onClose} statusBarTranslucent> | ||
| 134 | <View style={{ flex: 1 }}> | ||
| 135 | <Animated.View | ||
| 136 | pointerEvents="none" | ||
| 137 | style={[ | ||
| 138 | { | ||
| 139 | position: "absolute", | ||
| 140 | left: 0, | ||
| 141 | right: 0, | ||
| 142 | top: 0, | ||
| 143 | bottom: 0, | ||
| 144 | backgroundColor: backdropColor, | ||
| 145 | }, | ||
| 146 | backdropStyle, | ||
| 147 | ]} | ||
| 148 | /> | ||
| 149 | <Pressable | ||
| 150 | style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }} | ||
| 151 | onPress={props.onClose} | ||
| 152 | /> | ||
| 153 | |||
| 154 | <GestureDetector gesture={panGesture}> | ||
| 155 | <Animated.View | ||
| 156 | style={[ | ||
| 157 | { | ||
| 158 | position: "absolute", | ||
| 159 | left: 0, | ||
| 160 | top: 0, | ||
| 161 | bottom: 0, | ||
| 162 | width: drawerWidth, | ||
| 163 | backgroundColor: drawerBg, | ||
| 164 | paddingTop: insets.top + 10, | ||
| 165 | paddingBottom: Math.max(insets.bottom, 18), | ||
| 166 | boxShadow: `20px 0 36px ${String(drawerShadow)}`, | ||
| 167 | }, | ||
| 168 | drawerStyle, | ||
| 169 | ]} | ||
| 170 | > | ||
| 171 | <View className="flex-row items-center justify-between px-4 pb-5"> | ||
| 172 | <Text className="text-[26px] font-t3-bold">Threads</Text> | ||
| 173 | <Pressable | ||
| 174 | onPress={() => { | ||
| 175 | props.onClose(); | ||
| 176 | props.onStartNewTask(); | ||
| 177 | }} | ||
| 178 | className="h-11 w-11 items-center justify-center rounded-full bg-primary" | ||
| 179 | > | ||
| 180 | <SymbolView | ||
| 181 | name="square.and.pencil" | ||
| 182 | size={17} | ||
| 183 | tintColor={primaryForeground} | ||
| 184 | type="monochrome" | ||
| 185 | /> | ||
| 186 | </Pressable> | ||
| 187 | </View> | ||
| 188 | |||
| 189 | <ScrollView | ||
| 190 | showsVerticalScrollIndicator={false} | ||
| 191 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 12 }} | ||
| 192 | contentContainerStyle={{ | ||
| 193 | gap: 20, | ||
| 194 | paddingHorizontal: 14, | ||
| 195 | }} | ||
| 196 | > | ||
| 197 | {groupedThreads.map((group) => ( | ||
| 198 | <View key={group.key} className="gap-3"> | ||
| 199 | <Text | ||
| 200 | className="px-1 text-[15px] font-t3-bold text-foreground-muted" | ||
| 201 | style={{ letterSpacing: -0.2 }} | ||
| 202 | > | ||
| 203 | {group.title} | ||
| 204 | </Text> | ||
| 205 | |||
| 206 | <View className="overflow-hidden rounded-[22px] bg-card"> | ||
| 207 | {group.threads.length === 0 ? ( | ||
| 208 | <View className="px-4 py-4"> | ||
| 209 | <Text className="text-[14px] font-medium text-foreground-tertiary"> | ||
| 210 | No threads yet | ||
| 211 | </Text> | ||
| 212 | </View> | ||
| 213 | ) : ( | ||
| 214 | group.threads.map((thread, index) => { | ||
| 215 | const threadKey = scopedThreadKey(thread.environmentId, thread.id); | ||
| 216 | const selected = props.selectedThreadKey === threadKey; | ||
| 217 | |||
| 218 | return ( | ||
| 219 | <Pressable | ||
| 220 | key={threadKey} | ||
| 221 | onPress={() => { | ||
| 222 | props.onSelectThread(thread); | ||
| 223 | props.onClose(); | ||
| 224 | }} | ||
| 225 | style={{ | ||
| 226 | paddingHorizontal: 16, | ||
| 227 | paddingVertical: 15, | ||
| 228 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 229 | borderTopColor: borderSubtleColor, | ||
| 230 | backgroundColor: selected ? undefined : "transparent", | ||
| 231 | }} | ||
| 232 | className={selected ? "bg-subtle" : undefined} | ||
| 233 | > | ||
| 234 | <View className="flex-row items-start justify-between gap-3"> | ||
| 235 | <View className="flex-1 gap-1"> | ||
| 236 | <Text className="text-[16px] font-t3-bold" numberOfLines={1}> | ||
| 237 | {thread.title} | ||
| 238 | </Text> | ||
| 239 | <Text | ||
| 240 | className="text-[13px] font-medium text-foreground-muted" | ||
| 241 | numberOfLines={1} | ||
| 242 | > | ||
| 243 | {relativeTime(thread.updatedAt ?? thread.createdAt)} | ||
| 244 | </Text> | ||
| 245 | </View> | ||
| 246 | <StatusPill {...threadStatusTone(thread)} /> | ||
| 247 | </View> | ||
| 248 | </Pressable> | ||
| 249 | ); | ||
| 250 | }) | ||
| 251 | )} | ||
| 252 | </View> | ||
| 253 | </View> | ||
| 254 | ))} | ||
| 255 | </ScrollView> | ||
| 256 | </Animated.View> | ||
| 257 | </GestureDetector> | ||
| 258 | </View> | ||
| 259 | </Modal> | ||
| 260 | ); | ||
| 261 | } | ||
apps/mobile/src/features/threads/ThreadRouteScreen.tsx created+418| ... | @@ -0,0 +1,418 @@ | ||
| 1 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | ||
| 2 | import { useCallback, useMemo, useState } from "react"; | ||
| 3 | import * as Arr from "effect/Array"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { pipe } from "effect/Function"; | ||
| 6 | import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; | ||
| 7 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | ||
| 8 | import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; | ||
| 9 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 10 | import { useVcsStatus, vcsStatusManager } from "../../state/use-vcs-status"; | ||
| 11 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; | ||
| 12 | |||
| 13 | import { EmptyState } from "../../components/EmptyState"; | ||
| 14 | import { LoadingScreen } from "../../components/LoadingScreen"; | ||
| 15 | import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes"; | ||
| 16 | import { scopedThreadKey } from "../../lib/scopedEntities"; | ||
| 17 | import { connectionTone } from "../connection/connectionTone"; | ||
| 18 | |||
| 19 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | ||
| 20 | import { | ||
| 21 | useRemoteConnectionStatus, | ||
| 22 | useRemoteEnvironmentState, | ||
| 23 | } from "../../state/use-remote-environment-registry"; | ||
| 24 | import { useKnownTerminalSessions } from "../../state/use-terminal-session"; | ||
| 25 | import { useSelectedThreadDetail } from "../../state/use-thread-detail"; | ||
| 26 | import { useThreadSelection } from "../../state/use-thread-selection"; | ||
| 27 | import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; | ||
| 28 | import { | ||
| 29 | buildTerminalMenuSessions, | ||
| 30 | nextOpenTerminalId, | ||
| 31 | resolveProjectScriptTerminalId, | ||
| 32 | } from "../terminal/terminalMenu"; | ||
| 33 | import { | ||
| 34 | resolvePreferredThreadWorktreePath, | ||
| 35 | stagePendingTerminalLaunch, | ||
| 36 | } from "../terminal/terminalLaunchContext"; | ||
| 37 | import { terminalDebugLog } from "../terminal/terminalDebugLog"; | ||
| 38 | import { ThreadDetailScreen } from "./ThreadDetailScreen"; | ||
| 39 | import { ThreadGitControls } from "./ThreadGitControls"; | ||
| 40 | import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; | ||
| 41 | import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands"; | ||
| 42 | import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; | ||
| 43 | import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; | ||
| 44 | import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; | ||
| 45 | import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; | ||
| 46 | import { useThreadComposerState } from "../../state/use-thread-composer-state"; | ||
| 47 | |||
| 48 | function firstRouteParam(value: string | string[] | undefined): string | null { | ||
| 49 | if (Array.isArray(value)) { | ||
| 50 | return value[0] ?? null; | ||
| 51 | } | ||
| 52 | |||
| 53 | return value ?? null; | ||
| 54 | } | ||
| 55 | |||
| 56 | export function ThreadRouteScreen() { | ||
| 57 | const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } = | ||
| 58 | useRemoteEnvironmentState(); | ||
| 59 | const { connectionState, connectionError: aggregateConnectionError } = | ||
| 60 | useRemoteConnectionStatus(); | ||
| 61 | const { projects, threads } = useRemoteCatalog(); | ||
| 62 | const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = | ||
| 63 | useThreadSelection(); | ||
| 64 | const selectedThreadDetail = useSelectedThreadDetail(); | ||
| 65 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | ||
| 66 | const composer = useThreadComposerState(); | ||
| 67 | const gitState = useSelectedThreadGitState(); | ||
| 68 | const gitActions = useSelectedThreadGitActions(); | ||
| 69 | const requests = useSelectedThreadRequests(); | ||
| 70 | const commands = useSelectedThreadCommands({ | ||
| 71 | refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus, | ||
| 72 | }); | ||
| 73 | const refreshSelectedThread = commands.onRefresh; | ||
| 74 | const router = useRouter(); | ||
| 75 | const params = useLocalSearchParams<{ | ||
| 76 | environmentId?: string | string[]; | ||
| 77 | threadId?: string | string[]; | ||
| 78 | }>(); | ||
| 79 | const [drawerVisible, setDrawerVisible] = useState(false); | ||
| 80 | const environmentIdRaw = firstRouteParam(params.environmentId); | ||
| 81 | const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; | ||
| 82 | const threadId = firstRouteParam(params.threadId); | ||
| 83 | const routeEnvironmentRuntime = environmentId | ||
| 84 | ? (environmentStateById[environmentId] ?? null) | ||
| 85 | : null; | ||
| 86 | const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? connectionState; | ||
| 87 | const routeConnectionError = | ||
| 88 | pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError; | ||
| 89 | |||
| 90 | /* ─── Native header theming ──────────────────────────────────────── */ | ||
| 91 | const isDark = useColorScheme() === "dark"; | ||
| 92 | const iconColor = String(useThemeColor("--color-icon")); | ||
| 93 | const foregroundColor = String(useThemeColor("--color-foreground")); | ||
| 94 | const secondaryFg = isDark ? "#a3a3a3" : "#525252"; | ||
| 95 | |||
| 96 | /* ─── Git status for native header trigger ───────────────────────── */ | ||
| 97 | const gitStatus = useVcsStatus({ | ||
| 98 | environmentId: selectedThread?.environmentId ?? null, | ||
| 99 | cwd: selectedThreadCwd, | ||
| 100 | }); | ||
| 101 | const knownTerminalSessions = useKnownTerminalSessions({ | ||
| 102 | environmentId: selectedThread?.environmentId ?? null, | ||
| 103 | threadId: selectedThread?.id ?? null, | ||
| 104 | }); | ||
| 105 | const terminalMenuSessions = useMemo( | ||
| 106 | () => | ||
| 107 | buildTerminalMenuSessions({ | ||
| 108 | knownSessions: knownTerminalSessions, | ||
| 109 | workspaceRoot: selectedThreadProject?.workspaceRoot ?? null, | ||
| 110 | }), | ||
| 111 | [knownTerminalSessions, selectedThreadProject?.workspaceRoot], | ||
| 112 | ); | ||
| 113 | const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; | ||
| 114 | |||
| 115 | /* ─── Git action progress (for overlay banner) ──────────────────── */ | ||
| 116 | const gitActionProgressTarget = useMemo( | ||
| 117 | () => ({ | ||
| 118 | environmentId: selectedThread?.environmentId ?? null, | ||
| 119 | cwd: selectedThreadCwd, | ||
| 120 | }), | ||
| 121 | [selectedThread?.environmentId, selectedThreadCwd], | ||
| 122 | ); | ||
| 123 | const gitActionProgress = useGitActionProgress(gitActionProgressTarget); | ||
| 124 | |||
| 125 | const handleRefreshGitStatus = useCallback(async () => { | ||
| 126 | if (!selectedThread) return; | ||
| 127 | await vcsStatusManager.refresh({ | ||
| 128 | environmentId: selectedThread.environmentId, | ||
| 129 | cwd: selectedThreadCwd, | ||
| 130 | }); | ||
| 131 | }, [selectedThread, selectedThreadCwd]); | ||
| 132 | |||
| 133 | /** Wraps thread refresh + git status refresh for pull-to-refresh */ | ||
| 134 | const handleRefreshAll = useCallback(async () => { | ||
| 135 | await refreshSelectedThread(); | ||
| 136 | await handleRefreshGitStatus(); | ||
| 137 | }, [handleRefreshGitStatus, refreshSelectedThread]); | ||
| 138 | |||
| 139 | const handleOpenDrawer = useCallback(() => { | ||
| 140 | setDrawerVisible(true); | ||
| 141 | }, []); | ||
| 142 | |||
| 143 | const handleOpenConnectionEditor = useCallback(() => { | ||
| 144 | void router.push("/connections"); | ||
| 145 | }, [router]); | ||
| 146 | |||
| 147 | const handleOpenTerminal = useCallback( | ||
| 148 | (nextTerminalId?: string | null) => { | ||
| 149 | terminalDebugLog("terminal-menu:open-existing", { | ||
| 150 | terminalId: nextTerminalId ?? null, | ||
| 151 | hasThread: Boolean(selectedThread), | ||
| 152 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), | ||
| 153 | }); | ||
| 154 | |||
| 155 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | ||
| 156 | return; | ||
| 157 | } | ||
| 158 | |||
| 159 | void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); | ||
| 160 | }, | ||
| 161 | [router, selectedThread, selectedThreadProject?.workspaceRoot], | ||
| 162 | ); | ||
| 163 | |||
| 164 | const handleOpenNewTerminal = useCallback(() => { | ||
| 165 | terminalDebugLog("terminal-menu:open-new", { | ||
| 166 | hasThread: Boolean(selectedThread), | ||
| 167 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), | ||
| 168 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | ||
| 169 | }); | ||
| 170 | |||
| 171 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | ||
| 172 | return; | ||
| 173 | } | ||
| 174 | |||
| 175 | const nextId = nextOpenTerminalId({ | ||
| 176 | listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | ||
| 177 | }); | ||
| 178 | void router.push(buildThreadTerminalNavigation(selectedThread, nextId)); | ||
| 179 | }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); | ||
| 180 | |||
| 181 | const handleRunProjectScript = useCallback( | ||
| 182 | async (script: ProjectScript) => { | ||
| 183 | terminalDebugLog("project-script:press", { | ||
| 184 | scriptId: script.id, | ||
| 185 | command: script.command, | ||
| 186 | hasThread: Boolean(selectedThread), | ||
| 187 | hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), | ||
| 188 | }); | ||
| 189 | |||
| 190 | if (!selectedThread || !selectedThreadProject?.workspaceRoot) { | ||
| 191 | terminalDebugLog("project-script:abort", { | ||
| 192 | scriptId: script.id, | ||
| 193 | reason: "no-thread-or-workspace", | ||
| 194 | }); | ||
| 195 | return; | ||
| 196 | } | ||
| 197 | |||
| 198 | const targetTerminalId = resolveProjectScriptTerminalId({ | ||
| 199 | existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId), | ||
| 200 | hasRunningTerminal: terminalMenuSessions.some( | ||
| 201 | (session) => session.status === "running" || session.status === "starting", | ||
| 202 | ), | ||
| 203 | }); | ||
| 204 | const preferredWorktreePath = resolvePreferredThreadWorktreePath({ | ||
| 205 | threadShellWorktreePath: selectedThread.worktreePath ?? null, | ||
| 206 | threadDetailWorktreePath: selectedThreadDetailWorktreePath, | ||
| 207 | }); | ||
| 208 | const cwd = projectScriptCwd({ | ||
| 209 | project: { cwd: selectedThreadProject.workspaceRoot }, | ||
| 210 | worktreePath: preferredWorktreePath, | ||
| 211 | }); | ||
| 212 | const env = projectScriptRuntimeEnv({ | ||
| 213 | project: { cwd: selectedThreadProject.workspaceRoot }, | ||
| 214 | worktreePath: preferredWorktreePath, | ||
| 215 | }); | ||
| 216 | stagePendingTerminalLaunch({ | ||
| 217 | target: { | ||
| 218 | environmentId: selectedThread.environmentId, | ||
| 219 | threadId: selectedThread.id, | ||
| 220 | terminalId: targetTerminalId, | ||
| 221 | }, | ||
| 222 | launch: { | ||
| 223 | cwd, | ||
| 224 | worktreePath: preferredWorktreePath, | ||
| 225 | env, | ||
| 226 | initialInput: `${script.command}\r`, | ||
| 227 | }, | ||
| 228 | }); | ||
| 229 | terminalDebugLog("project-script:staged", { | ||
| 230 | scriptId: script.id, | ||
| 231 | terminalId: targetTerminalId, | ||
| 232 | cwd, | ||
| 233 | worktreePath: preferredWorktreePath, | ||
| 234 | }); | ||
| 235 | |||
| 236 | void router.push(buildThreadTerminalNavigation(selectedThread, targetTerminalId)); | ||
| 237 | }, | ||
| 238 | [ | ||
| 239 | router, | ||
| 240 | selectedThread, | ||
| 241 | selectedThreadDetailWorktreePath, | ||
| 242 | selectedThreadProject, | ||
| 243 | terminalMenuSessions, | ||
| 244 | ], | ||
| 245 | ); | ||
| 246 | |||
| 247 | if (!environmentId || !threadId) { | ||
| 248 | return <LoadingScreen message="Opening thread…" />; | ||
| 249 | } | ||
| 250 | |||
| 251 | if (!selectedThread) { | ||
| 252 | const stillHydrating = | ||
| 253 | isLoadingSavedConnection || | ||
| 254 | routeConnectionState === "connecting" || | ||
| 255 | routeConnectionState === "reconnecting"; | ||
| 256 | |||
| 257 | if (stillHydrating) { | ||
| 258 | return <LoadingScreen message="Opening thread…" />; | ||
| 259 | } | ||
| 260 | |||
| 261 | return ( | ||
| 262 | <ScrollView | ||
| 263 | contentInsetAdjustmentBehavior="automatic" | ||
| 264 | contentContainerStyle={{ | ||
| 265 | flexGrow: 1, | ||
| 266 | justifyContent: "center", | ||
| 267 | paddingHorizontal: 24, | ||
| 268 | paddingVertical: 32, | ||
| 269 | }} | ||
| 270 | className="bg-screen flex-1" | ||
| 271 | > | ||
| 272 | <EmptyState | ||
| 273 | title="Thread unavailable" | ||
| 274 | detail="This thread is not available in the current mobile snapshot." | ||
| 275 | /> | ||
| 276 | </ScrollView> | ||
| 277 | ); | ||
| 278 | } | ||
| 279 | |||
| 280 | if (!selectedThreadDetail) { | ||
| 281 | return <LoadingScreen message="Opening thread…" />; | ||
| 282 | } | ||
| 283 | |||
| 284 | const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); | ||
| 285 | const serverConfig = | ||
| 286 | routeEnvironmentRuntime?.serverConfig ?? | ||
| 287 | pipe( | ||
| 288 | Object.values(environmentStateById), | ||
| 289 | Arr.map((runtime) => runtime.serverConfig), | ||
| 290 | Arr.findFirst((value) => value !== null), | ||
| 291 | Option.getOrNull, | ||
| 292 | ); | ||
| 293 | |||
| 294 | const headerSubtitle = [ | ||
| 295 | selectedThreadProject?.title ?? null, | ||
| 296 | selectedEnvironmentConnection?.environmentLabel ?? null, | ||
| 297 | ] | ||
| 298 | .filter(Boolean) | ||
| 299 | .join(" · "); | ||
| 300 | |||
| 301 | return ( | ||
| 302 | <> | ||
| 303 | <Stack.Screen | ||
| 304 | options={{ | ||
| 305 | headerShown: true, | ||
| 306 | headerTransparent: true, | ||
| 307 | headerStyle: { backgroundColor: "transparent" }, | ||
| 308 | headerShadowVisible: false, | ||
| 309 | headerTintColor: iconColor, | ||
| 310 | headerBackTitle: "", | ||
| 311 | headerTitle: () => ( | ||
| 312 | <Pressable | ||
| 313 | style={{ alignItems: "center", maxWidth: 200 }} | ||
| 314 | onLongPress={() => { | ||
| 315 | // TODO: trigger rename modal | ||
| 316 | }} | ||
| 317 | > | ||
| 318 | <RNText | ||
| 319 | numberOfLines={1} | ||
| 320 | style={{ | ||
| 321 | fontFamily: "DMSans_700Bold", | ||
| 322 | fontSize: 18, | ||
| 323 | fontWeight: "900", | ||
| 324 | color: foregroundColor, | ||
| 325 | letterSpacing: -0.4, | ||
| 326 | }} | ||
| 327 | > | ||
| 328 | {selectedThreadDetail.title} | ||
| 329 | </RNText> | ||
| 330 | <RNText | ||
| 331 | numberOfLines={1} | ||
| 332 | style={{ | ||
| 333 | fontFamily: "DMSans_700Bold", | ||
| 334 | fontSize: 12, | ||
| 335 | fontWeight: "700", | ||
| 336 | color: secondaryFg, | ||
| 337 | letterSpacing: 0.3, | ||
| 338 | }} | ||
| 339 | > | ||
| 340 | {headerSubtitle} | ||
| 341 | </RNText> | ||
| 342 | </Pressable> | ||
| 343 | ), | ||
| 344 | }} | ||
| 345 | /> | ||
| 346 | |||
| 347 | <ThreadGitControls | ||
| 348 | currentBranch={selectedThreadDetail.branch} | ||
| 349 | gitStatus={gitStatus.data} | ||
| 350 | gitOperationLabel={gitState.gitOperationLabel} | ||
| 351 | canOpenTerminal={Boolean(selectedThreadProject?.workspaceRoot)} | ||
| 352 | projectScripts={selectedThreadProject?.scripts ?? []} | ||
| 353 | terminalSessions={terminalMenuSessions} | ||
| 354 | onOpenTerminal={handleOpenTerminal} | ||
| 355 | onOpenNewTerminal={handleOpenNewTerminal} | ||
| 356 | onRunProjectScript={handleRunProjectScript} | ||
| 357 | onPull={gitActions.onPullSelectedThreadBranch} | ||
| 358 | onRunAction={gitActions.onRunSelectedThreadGitAction} | ||
| 359 | /> | ||
| 360 | |||
| 361 | <GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} /> | ||
| 362 | |||
| 363 | <View className="flex-1 bg-screen"> | ||
| 364 | <ThreadDetailScreen | ||
| 365 | selectedThread={selectedThreadDetail} | ||
| 366 | screenTone={connectionTone(routeConnectionState)} | ||
| 367 | connectionError={routeConnectionError} | ||
| 368 | httpBaseUrl={selectedEnvironmentConnection?.httpBaseUrl ?? null} | ||
| 369 | bearerToken={selectedEnvironmentConnection?.bearerToken ?? null} | ||
| 370 | selectedThreadFeed={composer.selectedThreadFeed} | ||
| 371 | activeWorkStartedAt={composer.activeWorkStartedAt} | ||
| 372 | activePendingApproval={requests.activePendingApproval} | ||
| 373 | respondingApprovalId={requests.respondingApprovalId} | ||
| 374 | activePendingUserInput={requests.activePendingUserInput} | ||
| 375 | activePendingUserInputDrafts={requests.activePendingUserInputDrafts} | ||
| 376 | activePendingUserInputAnswers={requests.activePendingUserInputAnswers} | ||
| 377 | respondingUserInputId={requests.respondingUserInputId} | ||
| 378 | draftMessage={composer.draftMessage} | ||
| 379 | draftAttachments={composer.draftAttachments} | ||
| 380 | connectionStateLabel={routeConnectionState} | ||
| 381 | activeThreadBusy={composer.activeThreadBusy} | ||
| 382 | environmentId={selectedThread.environmentId} | ||
| 383 | projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} | ||
| 384 | selectedThreadQueueCount={composer.selectedThreadQueueCount} | ||
| 385 | onOpenDrawer={handleOpenDrawer} | ||
| 386 | onOpenConnectionEditor={handleOpenConnectionEditor} | ||
| 387 | onChangeDraftMessage={composer.onChangeDraftMessage} | ||
| 388 | onPickDraftImages={composer.onPickDraftImages} | ||
| 389 | onNativePasteImages={composer.onNativePasteImages} | ||
| 390 | onRemoveDraftImage={composer.onRemoveDraftImage} | ||
| 391 | onRefresh={handleRefreshAll} | ||
| 392 | serverConfig={serverConfig} | ||
| 393 | onStopThread={commands.onStopThread} | ||
| 394 | onSendMessage={composer.onSendMessage} | ||
| 395 | onUpdateThreadModelSelection={commands.onUpdateThreadModelSelection} | ||
| 396 | onUpdateThreadRuntimeMode={commands.onUpdateThreadRuntimeMode} | ||
| 397 | onUpdateThreadInteractionMode={commands.onUpdateThreadInteractionMode} | ||
| 398 | onRespondToApproval={requests.onRespondToApproval} | ||
| 399 | onSelectUserInputOption={requests.onSelectUserInputOption} | ||
| 400 | onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer} | ||
| 401 | onSubmitUserInput={requests.onSubmitUserInput} | ||
| 402 | /> | ||
| 403 | |||
| 404 | <ThreadNavigationDrawer | ||
| 405 | visible={drawerVisible} | ||
| 406 | projects={projects} | ||
| 407 | threads={threads} | ||
| 408 | selectedThreadKey={selectedThreadKey} | ||
| 409 | onClose={() => setDrawerVisible(false)} | ||
| 410 | onSelectThread={(thread) => { | ||
| 411 | router.replace(buildThreadRoutePath(thread)); | ||
| 412 | }} | ||
| 413 | onStartNewTask={() => router.push("/new")} | ||
| 414 | /> | ||
| 415 | </View> | ||
| 416 | </> | ||
| 417 | ); | ||
| 418 | } | ||
apps/mobile/src/features/threads/claudeEffortOptions.ts created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | export const CLAUDE_AGENT_EFFORT_OPTIONS = [ | ||
| 2 | "low", | ||
| 3 | "medium", | ||
| 4 | "high", | ||
| 5 | "xhigh", | ||
| 6 | "max", | ||
| 7 | "ultrathink", | ||
| 8 | ] as const; | ||
| 9 | |||
| 10 | export type ClaudeAgentEffort = (typeof CLAUDE_AGENT_EFFORT_OPTIONS)[number]; | ||
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx created+201| ... | @@ -0,0 +1,201 @@ | ||
| 1 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 2 | import { useRouter } from "expo-router"; | ||
| 3 | import { useState } from "react"; | ||
| 4 | import { Pressable, ScrollView, View } from "react-native"; | ||
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 6 | import { useThemeColor } from "../../../lib/useThemeColor"; | ||
| 7 | |||
| 8 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; | ||
| 9 | import { useVcsStatus } from "../../../state/use-vcs-status"; | ||
| 10 | import { useThreadSelection } from "../../../state/use-thread-selection"; | ||
| 11 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; | ||
| 12 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; | ||
| 13 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; | ||
| 14 | import { SheetActionButton } from "./gitSheetComponents"; | ||
| 15 | |||
| 16 | export function GitBranchesSheet() { | ||
| 17 | const router = useRouter(); | ||
| 18 | const insets = useSafeAreaInsets(); | ||
| 19 | const { selectedThread } = useThreadSelection(); | ||
| 20 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); | ||
| 21 | const gitState = useSelectedThreadGitState(); | ||
| 22 | const gitActions = useSelectedThreadGitActions(); | ||
| 23 | |||
| 24 | const borderColor = useThemeColor("--color-border"); | ||
| 25 | const inputBorderColor = useThemeColor("--color-input-border"); | ||
| 26 | const inputBg = useThemeColor("--color-input"); | ||
| 27 | const foregroundColor = useThemeColor("--color-foreground"); | ||
| 28 | const subtleStrongColor = useThemeColor("--color-subtle-strong"); | ||
| 29 | |||
| 30 | const gitStatus = useVcsStatus({ | ||
| 31 | environmentId: selectedThread?.environmentId ?? null, | ||
| 32 | cwd: selectedThreadCwd, | ||
| 33 | }); | ||
| 34 | |||
| 35 | const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; | ||
| 36 | const currentWorktreePath = selectedThreadWorktreePath; | ||
| 37 | const availableBranches = gitState.selectedThreadBranches; | ||
| 38 | const branchesLoading = gitState.selectedThreadBranchesLoading; | ||
| 39 | const busy = gitState.gitOperationLabel !== null; | ||
| 40 | |||
| 41 | const [newBranchName, setNewBranchName] = useState(""); | ||
| 42 | const [worktreeBaseBranch, setWorktreeBaseBranch] = useState( | ||
| 43 | currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel, | ||
| 44 | ); | ||
| 45 | const [worktreeBranchName, setWorktreeBranchName] = useState(""); | ||
| 46 | |||
| 47 | const disabledExistingBranchNames: Array<string> = []; | ||
| 48 | for (const branch of availableBranches) { | ||
| 49 | if (branch.worktreePath !== null && branch.worktreePath !== currentWorktreePath) { | ||
| 50 | disabledExistingBranchNames.push(branch.name); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | const disabledExistingBranches = new Set(disabledExistingBranchNames); | ||
| 54 | |||
| 55 | return ( | ||
| 56 | <ScrollView | ||
| 57 | showsVerticalScrollIndicator={false} | ||
| 58 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 59 | contentContainerStyle={{ | ||
| 60 | paddingHorizontal: 20, | ||
| 61 | paddingTop: 8, | ||
| 62 | gap: 16, | ||
| 63 | }} | ||
| 64 | > | ||
| 65 | <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4"> | ||
| 66 | <Text | ||
| 67 | className="text-foreground-secondary text-[11px] font-t3-bold uppercase" | ||
| 68 | style={{ letterSpacing: 1 }} | ||
| 69 | > | ||
| 70 | New branch | ||
| 71 | </Text> | ||
| 72 | <TextInput | ||
| 73 | value={newBranchName} | ||
| 74 | onChangeText={setNewBranchName} | ||
| 75 | placeholder="feature/mobile-polish" | ||
| 76 | className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" | ||
| 77 | style={{ | ||
| 78 | borderWidth: 1, | ||
| 79 | borderColor: inputBorderColor, | ||
| 80 | backgroundColor: inputBg, | ||
| 81 | color: foregroundColor, | ||
| 82 | }} | ||
| 83 | /> | ||
| 84 | <SheetActionButton | ||
| 85 | icon="plus" | ||
| 86 | label="Create & checkout" | ||
| 87 | tone="primary" | ||
| 88 | disabled={busy || newBranchName.trim().length === 0} | ||
| 89 | onPress={() => { | ||
| 90 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); | ||
| 91 | if (branch.length === 0) return; | ||
| 92 | void gitActions.onCreateSelectedThreadBranch(branch).then(() => { | ||
| 93 | setNewBranchName(""); | ||
| 94 | router.dismiss(); | ||
| 95 | }); | ||
| 96 | }} | ||
| 97 | /> | ||
| 98 | </View> | ||
| 99 | |||
| 100 | <View className="gap-2 rounded-[18px] border border-border bg-card px-4 py-4"> | ||
| 101 | <Text | ||
| 102 | className="text-foreground-secondary text-[11px] font-t3-bold uppercase" | ||
| 103 | style={{ letterSpacing: 1 }} | ||
| 104 | > | ||
| 105 | New worktree | ||
| 106 | </Text> | ||
| 107 | <TextInput | ||
| 108 | value={worktreeBaseBranch} | ||
| 109 | onChangeText={setWorktreeBaseBranch} | ||
| 110 | placeholder="main" | ||
| 111 | className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" | ||
| 112 | style={{ | ||
| 113 | borderWidth: 1, | ||
| 114 | borderColor: inputBorderColor, | ||
| 115 | backgroundColor: inputBg, | ||
| 116 | color: foregroundColor, | ||
| 117 | }} | ||
| 118 | /> | ||
| 119 | <TextInput | ||
| 120 | value={worktreeBranchName} | ||
| 121 | onChangeText={setWorktreeBranchName} | ||
| 122 | placeholder="feature/mobile-thread" | ||
| 123 | className="rounded-[18px] px-3.5 py-3 font-sans text-[15px]" | ||
| 124 | style={{ | ||
| 125 | borderWidth: 1, | ||
| 126 | borderColor: inputBorderColor, | ||
| 127 | backgroundColor: inputBg, | ||
| 128 | color: foregroundColor, | ||
| 129 | }} | ||
| 130 | /> | ||
| 131 | <SheetActionButton | ||
| 132 | icon="square.split.2x1" | ||
| 133 | label="Create worktree" | ||
| 134 | tone="primary" | ||
| 135 | disabled={ | ||
| 136 | busy || worktreeBaseBranch.trim().length === 0 || worktreeBranchName.trim().length === 0 | ||
| 137 | } | ||
| 138 | onPress={() => { | ||
| 139 | const baseBranch = worktreeBaseBranch.trim(); | ||
| 140 | const newBranch = worktreeBranchName.trim(); | ||
| 141 | if (baseBranch.length === 0 || newBranch.length === 0) return; | ||
| 142 | void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { | ||
| 143 | setWorktreeBranchName(""); | ||
| 144 | router.dismiss(); | ||
| 145 | }); | ||
| 146 | }} | ||
| 147 | /> | ||
| 148 | </View> | ||
| 149 | |||
| 150 | <View className="gap-2"> | ||
| 151 | <Text | ||
| 152 | className="text-foreground-secondary text-[11px] font-t3-bold uppercase" | ||
| 153 | style={{ letterSpacing: 1 }} | ||
| 154 | > | ||
| 155 | Existing branches | ||
| 156 | </Text> | ||
| 157 | {branchesLoading ? ( | ||
| 158 | <Text className="text-foreground-secondary text-[13px] font-medium"> | ||
| 159 | Loading branches... | ||
| 160 | </Text> | ||
| 161 | ) : null} | ||
| 162 | {!branchesLoading && availableBranches.length === 0 ? ( | ||
| 163 | <Text className="text-foreground-secondary text-[13px] font-medium"> | ||
| 164 | No local branches found. | ||
| 165 | </Text> | ||
| 166 | ) : null} | ||
| 167 | {availableBranches.map((branch) => { | ||
| 168 | const disabled = disabledExistingBranches.has(branch.name); | ||
| 169 | const subtitle = branch.worktreePath | ||
| 170 | ? branch.worktreePath === currentWorktreePath | ||
| 171 | ? "Checked out in this thread" | ||
| 172 | : "Checked out in another worktree" | ||
| 173 | : branch.isDefault | ||
| 174 | ? "Default branch" | ||
| 175 | : "Local branch"; | ||
| 176 | |||
| 177 | return ( | ||
| 178 | <Pressable | ||
| 179 | key={branch.name} | ||
| 180 | className="gap-1 rounded-[18px] border px-4 py-3" | ||
| 181 | disabled={busy || disabled} | ||
| 182 | style={{ | ||
| 183 | borderColor: branch.current ? subtleStrongColor : borderColor, | ||
| 184 | opacity: busy || disabled ? 0.45 : 1, | ||
| 185 | }} | ||
| 186 | onPress={() => { | ||
| 187 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { | ||
| 188 | router.dismiss(); | ||
| 189 | }); | ||
| 190 | }} | ||
| 191 | > | ||
| 192 | <View className="absolute inset-0 rounded-[18px] bg-card" /> | ||
| 193 | <Text className="text-foreground text-[15px] font-t3-bold">{branch.name}</Text> | ||
| 194 | <Text className="text-foreground-secondary text-[12px] font-medium">{subtitle}</Text> | ||
| 195 | </Pressable> | ||
| 196 | ); | ||
| 197 | })} | ||
| 198 | </View> | ||
| 199 | </ScrollView> | ||
| 200 | ); | ||
| 201 | } | ||
apps/mobile/src/features/threads/git/GitCommitSheet.tsx created+241| ... | @@ -0,0 +1,241 @@ | ||
| 1 | import { useRouter } from "expo-router"; | ||
| 2 | import { useCallback, useState } from "react"; | ||
| 3 | import { Pressable, ScrollView, View, useColorScheme } from "react-native"; | ||
| 4 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 5 | import { useThemeColor } from "../../../lib/useThemeColor"; | ||
| 6 | |||
| 7 | import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; | ||
| 8 | import { useVcsStatus } from "../../../state/use-vcs-status"; | ||
| 9 | import { useThreadSelection } from "../../../state/use-thread-selection"; | ||
| 10 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; | ||
| 11 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; | ||
| 12 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; | ||
| 13 | import { SheetActionButton } from "./gitSheetComponents"; | ||
| 14 | |||
| 15 | export function GitCommitSheet() { | ||
| 16 | const router = useRouter(); | ||
| 17 | const insets = useSafeAreaInsets(); | ||
| 18 | const isDarkMode = useColorScheme() === "dark"; | ||
| 19 | const { selectedThread } = useThreadSelection(); | ||
| 20 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | ||
| 21 | const gitState = useSelectedThreadGitState(); | ||
| 22 | const gitActions = useSelectedThreadGitActions(); | ||
| 23 | |||
| 24 | const borderColor = useThemeColor("--color-border"); | ||
| 25 | const borderSubtleColor = useThemeColor("--color-border-subtle"); | ||
| 26 | const inputBorderColor = useThemeColor("--color-input-border"); | ||
| 27 | const inputBg = useThemeColor("--color-input"); | ||
| 28 | const foregroundColor = useThemeColor("--color-foreground"); | ||
| 29 | |||
| 30 | const gitStatus = useVcsStatus({ | ||
| 31 | environmentId: selectedThread?.environmentId ?? null, | ||
| 32 | cwd: selectedThreadCwd, | ||
| 33 | }); | ||
| 34 | |||
| 35 | const busy = gitState.gitOperationLabel !== null; | ||
| 36 | const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; | ||
| 37 | const allFiles = gitStatus.data?.workingTree?.files ?? []; | ||
| 38 | |||
| 39 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); | ||
| 40 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); | ||
| 41 | const [isEditingFiles, setIsEditingFiles] = useState(false); | ||
| 42 | |||
| 43 | const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path)); | ||
| 44 | const allSelected = excludedFiles.size === 0; | ||
| 45 | const noneSelected = selectedFiles.length === 0; | ||
| 46 | const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0); | ||
| 47 | const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0); | ||
| 48 | const selectedFilePreview = selectedFiles.slice(0, 3); | ||
| 49 | |||
| 50 | const runCommitAction = useCallback( | ||
| 51 | async (featureBranch: boolean) => { | ||
| 52 | const commitMessage = dialogCommitMessage.trim(); | ||
| 53 | router.dismiss(); | ||
| 54 | await gitActions.onRunSelectedThreadGitAction({ | ||
| 55 | action: "commit", | ||
| 56 | featureBranch, | ||
| 57 | ...(commitMessage ? { commitMessage } : {}), | ||
| 58 | ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), | ||
| 59 | }); | ||
| 60 | }, | ||
| 61 | [allSelected, dialogCommitMessage, gitActions, router, selectedFiles], | ||
| 62 | ); | ||
| 63 | |||
| 64 | return ( | ||
| 65 | <ScrollView | ||
| 66 | keyboardShouldPersistTaps="handled" | ||
| 67 | showsVerticalScrollIndicator={false} | ||
| 68 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 69 | contentContainerStyle={{ | ||
| 70 | paddingHorizontal: 20, | ||
| 71 | paddingTop: 8, | ||
| 72 | gap: 16, | ||
| 73 | }} | ||
| 74 | > | ||
| 75 | <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4"> | ||
| 76 | <View className="flex-row items-center justify-between gap-3"> | ||
| 77 | <Text className="text-foreground-muted text-[13px] font-medium">Branch</Text> | ||
| 78 | <Text className="text-foreground text-[15px] font-t3-bold"> | ||
| 79 | {gitStatus.data?.refName ?? "(detached HEAD)"} | ||
| 80 | </Text> | ||
| 81 | </View> | ||
| 82 | {isDefaultRef ? ( | ||
| 83 | <Text | ||
| 84 | className="text-[12px] leading-[18px]" | ||
| 85 | style={{ color: isDarkMode ? "#fbbf24" : "#b45309" }} | ||
| 86 | > | ||
| 87 | Warning: this is the default branch. | ||
| 88 | </Text> | ||
| 89 | ) : null} | ||
| 90 | </View> | ||
| 91 | |||
| 92 | <View className="gap-3 rounded-[22px] border border-border bg-card px-4 py-4"> | ||
| 93 | <View className="flex-row items-center justify-between gap-3"> | ||
| 94 | <View className="gap-1"> | ||
| 95 | <Text className="text-foreground text-[16px] font-t3-bold">Files</Text> | ||
| 96 | <Text className="text-foreground-muted text-[12px] leading-[18px]"> | ||
| 97 | {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} | ||
| 98 | </Text> | ||
| 99 | </View> | ||
| 100 | <View className="flex-row items-center gap-2"> | ||
| 101 | {!allSelected && isEditingFiles ? ( | ||
| 102 | <Pressable | ||
| 103 | className="bg-subtle rounded-full px-3 py-2" | ||
| 104 | onPress={() => setExcludedFiles(new Set())} | ||
| 105 | > | ||
| 106 | <Text className="text-foreground text-[11px] font-t3-bold uppercase">Reset</Text> | ||
| 107 | </Pressable> | ||
| 108 | ) : null} | ||
| 109 | <Pressable | ||
| 110 | className="bg-subtle rounded-full px-3 py-2" | ||
| 111 | onPress={() => setIsEditingFiles((current) => !current)} | ||
| 112 | > | ||
| 113 | <Text className="text-foreground text-[11px] font-t3-bold uppercase"> | ||
| 114 | {isEditingFiles ? "Done" : "Edit"} | ||
| 115 | </Text> | ||
| 116 | </Pressable> | ||
| 117 | </View> | ||
| 118 | </View> | ||
| 119 | |||
| 120 | {allFiles.length === 0 ? ( | ||
| 121 | <Text className="text-foreground-secondary text-[13px] leading-[19px]"> | ||
| 122 | No changed files are available to commit. | ||
| 123 | </Text> | ||
| 124 | ) : !isEditingFiles ? ( | ||
| 125 | <View className="gap-2"> | ||
| 126 | {selectedFilePreview.map((file) => ( | ||
| 127 | <View key={file.path} className="flex-row items-center justify-between gap-3"> | ||
| 128 | <Text className="text-foreground flex-1 text-[13px] font-medium" numberOfLines={1}> | ||
| 129 | {file.path} | ||
| 130 | </Text> | ||
| 131 | <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}> | ||
| 132 | +{file.insertions} | ||
| 133 | </Text> | ||
| 134 | <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}> | ||
| 135 | -{file.deletions} | ||
| 136 | </Text> | ||
| 137 | </View> | ||
| 138 | ))} | ||
| 139 | {selectedFiles.length > selectedFilePreview.length ? ( | ||
| 140 | <Text className="text-foreground-muted text-[12px] leading-[17px]"> | ||
| 141 | +{selectedFiles.length - selectedFilePreview.length} more files | ||
| 142 | </Text> | ||
| 143 | ) : null} | ||
| 144 | </View> | ||
| 145 | ) : ( | ||
| 146 | <View className="gap-2"> | ||
| 147 | {allFiles.map((file) => { | ||
| 148 | const included = !excludedFiles.has(file.path); | ||
| 149 | return ( | ||
| 150 | <Pressable | ||
| 151 | key={file.path} | ||
| 152 | className="rounded-[18px] border px-4 py-3" | ||
| 153 | style={{ | ||
| 154 | borderColor: included ? borderColor : borderSubtleColor, | ||
| 155 | }} | ||
| 156 | onPress={() => { | ||
| 157 | setExcludedFiles((current) => { | ||
| 158 | const next = new Set(current); | ||
| 159 | if (next.has(file.path)) { | ||
| 160 | next.delete(file.path); | ||
| 161 | } else { | ||
| 162 | next.add(file.path); | ||
| 163 | } | ||
| 164 | return next; | ||
| 165 | }); | ||
| 166 | }} | ||
| 167 | > | ||
| 168 | <View | ||
| 169 | className={`absolute inset-0 rounded-[18px] ${included ? "bg-card" : "bg-subtle"}`} | ||
| 170 | /> | ||
| 171 | <View className="flex-row items-start justify-between gap-3"> | ||
| 172 | <View className="flex-1 gap-1"> | ||
| 173 | <Text | ||
| 174 | selectable | ||
| 175 | className={`text-[13px] font-t3-bold ${included ? "text-foreground" : "text-foreground-muted"}`} | ||
| 176 | > | ||
| 177 | {file.path} | ||
| 178 | </Text> | ||
| 179 | {!included ? ( | ||
| 180 | <Text className="text-foreground-muted text-[11px] leading-[16px]"> | ||
| 181 | Excluded from this commit | ||
| 182 | </Text> | ||
| 183 | ) : null} | ||
| 184 | </View> | ||
| 185 | <View className="items-end gap-1"> | ||
| 186 | <Text className="text-[12px] font-t3-bold" style={{ color: "#10b981" }}> | ||
| 187 | +{file.insertions} | ||
| 188 | </Text> | ||
| 189 | <Text className="text-[12px] font-t3-bold" style={{ color: "#f43f5e" }}> | ||
| 190 | -{file.deletions} | ||
| 191 | </Text> | ||
| 192 | </View> | ||
| 193 | </View> | ||
| 194 | </Pressable> | ||
| 195 | ); | ||
| 196 | })} | ||
| 197 | </View> | ||
| 198 | )} | ||
| 199 | </View> | ||
| 200 | |||
| 201 | <View className="gap-2"> | ||
| 202 | <Text className="text-foreground text-[13px] font-t3-bold">Commit message</Text> | ||
| 203 | <TextInput | ||
| 204 | multiline | ||
| 205 | value={dialogCommitMessage} | ||
| 206 | onChangeText={setDialogCommitMessage} | ||
| 207 | placeholder="Leave empty to auto-generate" | ||
| 208 | textAlignVertical="top" | ||
| 209 | className="min-h-[128px] rounded-[20px] px-4 py-3.5 font-sans text-[15px]" | ||
| 210 | style={{ | ||
| 211 | minHeight: 128, | ||
| 212 | borderWidth: 1, | ||
| 213 | borderColor: inputBorderColor, | ||
| 214 | backgroundColor: inputBg, | ||
| 215 | color: foregroundColor, | ||
| 216 | }} | ||
| 217 | /> | ||
| 218 | </View> | ||
| 219 | |||
| 220 | <View className="flex-row gap-3"> | ||
| 221 | <View className="flex-1"> | ||
| 222 | <SheetActionButton | ||
| 223 | icon="arrow.branch" | ||
| 224 | label="Commit on new branch" | ||
| 225 | disabled={noneSelected || busy} | ||
| 226 | onPress={() => void runCommitAction(true)} | ||
| 227 | /> | ||
| 228 | </View> | ||
| 229 | <View className="flex-1"> | ||
| 230 | <SheetActionButton | ||
| 231 | icon="checkmark.circle" | ||
| 232 | label="Commit" | ||
| 233 | tone="primary" | ||
| 234 | disabled={noneSelected || busy} | ||
| 235 | onPress={() => void runCommitAction(false)} | ||
| 236 | /> | ||
| 237 | </View> | ||
| 238 | </View> | ||
| 239 | </ScrollView> | ||
| 240 | ); | ||
| 241 | } | ||
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx created+124| ... | @@ -0,0 +1,124 @@ | ||
| 1 | import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime"; | ||
| 2 | import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; | ||
| 3 | import * as Arr from "effect/Array"; | ||
| 4 | import * as Result from "effect/Result"; | ||
| 5 | import { useLocalSearchParams, useRouter } from "expo-router"; | ||
| 6 | import { useCallback, useMemo } from "react"; | ||
| 7 | import { View } from "react-native"; | ||
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 9 | |||
| 10 | import { AppText as Text } from "../../../components/AppText"; | ||
| 11 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; | ||
| 12 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; | ||
| 13 | import { SheetActionButton } from "./gitSheetComponents"; | ||
| 14 | |||
| 15 | export function GitConfirmSheet() { | ||
| 16 | const router = useRouter(); | ||
| 17 | const insets = useSafeAreaInsets(); | ||
| 18 | const gitState = useSelectedThreadGitState(); | ||
| 19 | const gitActions = useSelectedThreadGitActions(); | ||
| 20 | |||
| 21 | const params = useLocalSearchParams<{ | ||
| 22 | confirmAction?: string; | ||
| 23 | branchName?: string; | ||
| 24 | includesCommit?: string; | ||
| 25 | commitMessage?: string; | ||
| 26 | filePaths?: string; | ||
| 27 | }>(); | ||
| 28 | |||
| 29 | const confirmAction = params.confirmAction as | ||
| 30 | | "push" | ||
| 31 | | "create_pr" | ||
| 32 | | "commit_push" | ||
| 33 | | "commit_push_pr" | ||
| 34 | | undefined; | ||
| 35 | const branchName = params.branchName ?? ""; | ||
| 36 | const includesCommit = params.includesCommit === "true"; | ||
| 37 | |||
| 38 | const copy = useMemo( | ||
| 39 | () => | ||
| 40 | confirmAction | ||
| 41 | ? resolveDefaultBranchActionDialogCopy({ | ||
| 42 | action: confirmAction, | ||
| 43 | branchName, | ||
| 44 | includesCommit, | ||
| 45 | }) | ||
| 46 | : null, | ||
| 47 | [branchName, confirmAction, includesCommit], | ||
| 48 | ); | ||
| 49 | |||
| 50 | const continuePendingAction = useCallback(async () => { | ||
| 51 | if (!confirmAction) return; | ||
| 52 | router.dismissAll(); | ||
| 53 | await gitActions.onRunSelectedThreadGitAction({ | ||
| 54 | action: confirmAction, | ||
| 55 | ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), | ||
| 56 | ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), | ||
| 57 | }); | ||
| 58 | }, [confirmAction, gitActions, params, router]); | ||
| 59 | |||
| 60 | const movePendingActionToFeatureBranch = useCallback(async () => { | ||
| 61 | if (!confirmAction) return; | ||
| 62 | router.dismissAll(); | ||
| 63 | |||
| 64 | if (includesCommit) { | ||
| 65 | await gitActions.onRunSelectedThreadGitAction({ | ||
| 66 | action: confirmAction, | ||
| 67 | featureBranch: true, | ||
| 68 | ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), | ||
| 69 | ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), | ||
| 70 | }); | ||
| 71 | return; | ||
| 72 | } | ||
| 73 | |||
| 74 | const branches = | ||
| 75 | gitState.selectedThreadBranches.length > 0 | ||
| 76 | ? gitState.selectedThreadBranches | ||
| 77 | : await gitActions.refreshSelectedThreadBranches(); | ||
| 78 | const newBranchName = resolveAutoFeatureBranchName( | ||
| 79 | Arr.filterMap(branches, (branch) => | ||
| 80 | branch.isRemote ? Result.failVoid : Result.succeed(branch.name), | ||
| 81 | ), | ||
| 82 | ); | ||
| 83 | await gitActions.onCreateSelectedThreadBranch(newBranchName); | ||
| 84 | await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); | ||
| 85 | }, [confirmAction, gitActions, gitState.selectedThreadBranches, includesCommit, params, router]); | ||
| 86 | |||
| 87 | return ( | ||
| 88 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 89 | <View style={{ minHeight: 16, paddingTop: 8 }} /> | ||
| 90 | |||
| 91 | <View className="items-center gap-1 px-5 pb-3 pt-4"> | ||
| 92 | <Text | ||
| 93 | className="text-[12px] font-t3-bold uppercase text-foreground-muted" | ||
| 94 | style={{ letterSpacing: 1 }} | ||
| 95 | > | ||
| 96 | Confirm | ||
| 97 | </Text> | ||
| 98 | <Text className="text-center text-[28px] font-t3-bold"> | ||
| 99 | {copy?.title ?? "Run action on default branch?"} | ||
| 100 | </Text> | ||
| 101 | <Text className="text-center text-foreground-secondary text-[13px] font-medium leading-[19px]"> | ||
| 102 | {copy?.description ?? "Choose how to continue."} | ||
| 103 | </Text> | ||
| 104 | </View> | ||
| 105 | |||
| 106 | <View | ||
| 107 | className="gap-3 px-5" | ||
| 108 | style={{ paddingBottom: Math.max(insets.bottom, 18) + 8, paddingTop: 8 }} | ||
| 109 | > | ||
| 110 | <SheetActionButton | ||
| 111 | icon="arrow.right.circle" | ||
| 112 | label={copy?.continueLabel ?? "Continue"} | ||
| 113 | onPress={() => void continuePendingAction()} | ||
| 114 | /> | ||
| 115 | <SheetActionButton | ||
| 116 | icon="arrow.branch" | ||
| 117 | label="Feature branch & continue" | ||
| 118 | tone="primary" | ||
| 119 | onPress={() => void movePendingActionToFeatureBranch()} | ||
| 120 | /> | ||
| 121 | </View> | ||
| 122 | </View> | ||
| 123 | ); | ||
| 124 | } | ||
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx created+248| ... | @@ -0,0 +1,248 @@ | ||
| 1 | import { | ||
| 2 | type GitActionRequestInput, | ||
| 3 | buildMenuItems, | ||
| 4 | getGitActionDisabledReason, | ||
| 5 | requiresDefaultBranchConfirmation, | ||
| 6 | } from "@t3tools/client-runtime"; | ||
| 7 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 8 | import { useLocalSearchParams, useRouter } from "expo-router"; | ||
| 9 | import { SymbolView } from "expo-symbols"; | ||
| 10 | import { useCallback, useEffect, useMemo } from "react"; | ||
| 11 | import { Alert, Linking, Pressable, ScrollView, View } from "react-native"; | ||
| 12 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 13 | import { useThemeColor } from "../../../lib/useThemeColor"; | ||
| 14 | |||
| 15 | import { AppText as Text } from "../../../components/AppText"; | ||
| 16 | import { buildThreadReviewRoutePath } from "../../../lib/routes"; | ||
| 17 | import { useVcsStatus } from "../../../state/use-vcs-status"; | ||
| 18 | import { useThreadSelection } from "../../../state/use-thread-selection"; | ||
| 19 | import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; | ||
| 20 | import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; | ||
| 21 | import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; | ||
| 22 | import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; | ||
| 23 | |||
| 24 | export function GitOverviewSheet() { | ||
| 25 | const router = useRouter(); | ||
| 26 | const insets = useSafeAreaInsets(); | ||
| 27 | const { environmentId, threadId } = useLocalSearchParams<{ | ||
| 28 | environmentId: EnvironmentId; | ||
| 29 | threadId: ThreadId; | ||
| 30 | }>(); | ||
| 31 | const { selectedThread } = useThreadSelection(); | ||
| 32 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); | ||
| 33 | const gitState = useSelectedThreadGitState(); | ||
| 34 | const gitActions = useSelectedThreadGitActions(); | ||
| 35 | |||
| 36 | const iconColor = useThemeColor("--color-icon"); | ||
| 37 | const borderColor = useThemeColor("--color-border"); | ||
| 38 | |||
| 39 | const gitStatus = useVcsStatus({ | ||
| 40 | environmentId: selectedThread?.environmentId ?? null, | ||
| 41 | cwd: selectedThreadCwd, | ||
| 42 | }); | ||
| 43 | |||
| 44 | const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; | ||
| 45 | const currentWorktreePath = selectedThreadWorktreePath; | ||
| 46 | const gitOperationLabel = gitState.gitOperationLabel; | ||
| 47 | const busy = gitOperationLabel !== null; | ||
| 48 | const isRepo = gitStatus.data?.isRepo ?? true; | ||
| 49 | const hasPrimaryRemote = gitStatus.data?.hasPrimaryRemote ?? false; | ||
| 50 | const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; | ||
| 51 | |||
| 52 | const menuItems = useMemo( | ||
| 53 | () => (isRepo ? buildMenuItems(gitStatus.data, busy, hasPrimaryRemote) : []), | ||
| 54 | [busy, gitStatus.data, hasPrimaryRemote, isRepo], | ||
| 55 | ); | ||
| 56 | |||
| 57 | const sheetMenuItems = useMemo( | ||
| 58 | () => | ||
| 59 | menuItems.map((item) => ({ | ||
| 60 | item, | ||
| 61 | disabledReason: getGitActionDisabledReason({ | ||
| 62 | item, | ||
| 63 | gitStatus: gitStatus.data, | ||
| 64 | isBusy: busy, | ||
| 65 | hasOriginRemote: hasPrimaryRemote, | ||
| 66 | }), | ||
| 67 | })), | ||
| 68 | [busy, gitStatus.data, hasPrimaryRemote, menuItems], | ||
| 69 | ); | ||
| 70 | |||
| 71 | useEffect(() => { | ||
| 72 | void gitActions.refreshSelectedThreadGitStatus({ quiet: true }); | ||
| 73 | }, [gitActions]); | ||
| 74 | |||
| 75 | const openExistingPr = useCallback(async () => { | ||
| 76 | const prUrl = gitStatus.data?.pr?.state === "open" ? gitStatus.data.pr.url : null; | ||
| 77 | if (!prUrl) { | ||
| 78 | Alert.alert("No open PR", "This branch does not have an open pull request."); | ||
| 79 | return; | ||
| 80 | } | ||
| 81 | try { | ||
| 82 | await Linking.openURL(prUrl); | ||
| 83 | } catch (error) { | ||
| 84 | Alert.alert( | ||
| 85 | "Unable to open PR", | ||
| 86 | error instanceof Error ? error.message : "An error occurred.", | ||
| 87 | ); | ||
| 88 | } | ||
| 89 | }, [gitStatus.data]); | ||
| 90 | |||
| 91 | const runActionWithPrompt = useCallback( | ||
| 92 | async (input: GitActionRequestInput) => { | ||
| 93 | const confirmableAction = | ||
| 94 | input.action === "push" || | ||
| 95 | input.action === "create_pr" || | ||
| 96 | input.action === "commit_push" || | ||
| 97 | input.action === "commit_push_pr" | ||
| 98 | ? input.action | ||
| 99 | : null; | ||
| 100 | const branchName = gitStatus.data?.refName; | ||
| 101 | if ( | ||
| 102 | branchName && | ||
| 103 | confirmableAction && | ||
| 104 | !input.featureBranch && | ||
| 105 | requiresDefaultBranchConfirmation(input.action, isDefaultRef) | ||
| 106 | ) { | ||
| 107 | router.push({ | ||
| 108 | pathname: "/threads/[environmentId]/[threadId]/git-confirm", | ||
| 109 | params: { | ||
| 110 | environmentId, | ||
| 111 | threadId, | ||
| 112 | confirmAction: confirmableAction, | ||
| 113 | branchName, | ||
| 114 | includesCommit: String( | ||
| 115 | input.action === "commit_push" || input.action === "commit_push_pr", | ||
| 116 | ), | ||
| 117 | }, | ||
| 118 | }); | ||
| 119 | return; | ||
| 120 | } | ||
| 121 | |||
| 122 | router.dismiss(); | ||
| 123 | await gitActions.onRunSelectedThreadGitAction(input); | ||
| 124 | }, | ||
| 125 | [environmentId, gitActions, gitStatus.data, isDefaultRef, router, threadId], | ||
| 126 | ); | ||
| 127 | |||
| 128 | const onPressMenuItem = useCallback( | ||
| 129 | async (item: (typeof menuItems)[number]) => { | ||
| 130 | if (item.disabled) return; | ||
| 131 | if (item.kind === "open_pr") { | ||
| 132 | await openExistingPr(); | ||
| 133 | return; | ||
| 134 | } | ||
| 135 | if (item.dialogAction === "commit") { | ||
| 136 | router.push({ | ||
| 137 | pathname: "/threads/[environmentId]/[threadId]/git/commit", | ||
| 138 | params: { environmentId, threadId }, | ||
| 139 | }); | ||
| 140 | return; | ||
| 141 | } | ||
| 142 | if (item.dialogAction === "push") { | ||
| 143 | await runActionWithPrompt({ action: "push" }); | ||
| 144 | return; | ||
| 145 | } | ||
| 146 | if (item.dialogAction === "create_pr") { | ||
| 147 | await runActionWithPrompt({ action: "create_pr" }); | ||
| 148 | } | ||
| 149 | }, | ||
| 150 | [environmentId, openExistingPr, router, runActionWithPrompt, threadId], | ||
| 151 | ); | ||
| 152 | |||
| 153 | return ( | ||
| 154 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 155 | <View style={{ minHeight: 16, paddingTop: 8 }} /> | ||
| 156 | |||
| 157 | <View className="items-center gap-1 px-5 pb-3 pt-4"> | ||
| 158 | <Pressable | ||
| 159 | className="absolute right-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle" | ||
| 160 | style={{ zIndex: 1, opacity: busy ? 0.45 : 1 }} | ||
| 161 | disabled={busy} | ||
| 162 | onPress={() => void gitActions.refreshSelectedThreadGitStatus()} | ||
| 163 | > | ||
| 164 | <SymbolView | ||
| 165 | name="arrow.clockwise" | ||
| 166 | size={16} | ||
| 167 | tintColor={iconColor} | ||
| 168 | type="monochrome" | ||
| 169 | weight="medium" | ||
| 170 | /> | ||
| 171 | </Pressable> | ||
| 172 | <Text | ||
| 173 | className="text-[12px] font-t3-bold uppercase text-foreground-muted" | ||
| 174 | style={{ letterSpacing: 1 }} | ||
| 175 | > | ||
| 176 | Branch | ||
| 177 | </Text> | ||
| 178 | <Text className="text-[28px] font-t3-bold">{currentBranchLabel}</Text> | ||
| 179 | <Text className="text-foreground-secondary text-[13px] font-medium leading-[19px]"> | ||
| 180 | {statusSummary(gitStatus.data)} | ||
| 181 | </Text> | ||
| 182 | </View> | ||
| 183 | |||
| 184 | <ScrollView | ||
| 185 | showsVerticalScrollIndicator={false} | ||
| 186 | style={{ flex: 1 }} | ||
| 187 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| 188 | contentContainerStyle={{ | ||
| 189 | paddingHorizontal: 20, | ||
| 190 | paddingTop: 8, | ||
| 191 | gap: 14, | ||
| 192 | }} | ||
| 193 | > | ||
| 194 | <View className="overflow-hidden rounded-[22px] border border-border bg-card px-4 py-1"> | ||
| 195 | {sheetMenuItems.map(({ item, disabledReason }, index) => ( | ||
| 196 | <View key={`${item.id}-${item.label}`}> | ||
| 197 | {index > 0 ? ( | ||
| 198 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 199 | ) : null} | ||
| 200 | <SheetListRow | ||
| 201 | icon={menuItemIconName(item.icon)} | ||
| 202 | title={item.label} | ||
| 203 | subtitle={disabledReason} | ||
| 204 | disabled={item.disabled} | ||
| 205 | onPress={() => void onPressMenuItem(item)} | ||
| 206 | /> | ||
| 207 | </View> | ||
| 208 | ))} | ||
| 209 | {(gitStatus.data?.behindCount ?? 0) > 0 ? ( | ||
| 210 | <> | ||
| 211 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 212 | <SheetListRow | ||
| 213 | icon="arrow.down.circle" | ||
| 214 | title="Pull latest" | ||
| 215 | subtitle="Sync this branch with upstream" | ||
| 216 | disabled={busy || !isRepo} | ||
| 217 | onPress={() => void gitActions.onPullSelectedThreadBranch()} | ||
| 218 | /> | ||
| 219 | </> | ||
| 220 | ) : null} | ||
| 221 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 222 | <SheetListRow | ||
| 223 | icon="text.bubble" | ||
| 224 | title="Review changes" | ||
| 225 | subtitle="Inspect turn diffs, worktree changes, and base branch diff" | ||
| 226 | disabled={busy || !isRepo} | ||
| 227 | onPress={() => router.push(buildThreadReviewRoutePath({ environmentId, threadId }))} | ||
| 228 | /> | ||
| 229 | <View className="ml-12 h-px" style={{ backgroundColor: borderColor }} /> | ||
| 230 | <SheetListRow | ||
| 231 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 232 | title="Branches & worktrees" | ||
| 233 | subtitle="Switch branch, create branch, or move to a worktree" | ||
| 234 | disabled={busy || !isRepo} | ||
| 235 | onPress={() => | ||
| 236 | router.push({ | ||
| 237 | pathname: "/threads/[environmentId]/[threadId]/git/branches", | ||
| 238 | params: { environmentId, threadId }, | ||
| 239 | }) | ||
| 240 | } | ||
| 241 | /> | ||
| 242 | </View> | ||
| 243 | |||
| 244 | {currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null} | ||
| 245 | </ScrollView> | ||
| 246 | </View> | ||
| 247 | ); | ||
| 248 | } | ||
apps/mobile/src/features/threads/git/gitSheetComponents.tsx created+161| ... | @@ -0,0 +1,161 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import type { ComponentProps } from "react"; | ||
| 3 | import { Pressable, View } from "react-native"; | ||
| 4 | import { useThemeColor } from "../../../lib/useThemeColor"; | ||
| 5 | import { AppText as Text } from "../../../components/AppText"; | ||
| 6 | |||
| 7 | /* ─── Shared sheet components ──────────────────────────────────────── */ | ||
| 8 | |||
| 9 | export function SheetActionButton(props: { | ||
| 10 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | ||
| 11 | readonly label: string; | ||
| 12 | readonly disabled?: boolean; | ||
| 13 | readonly tone?: "primary" | "secondary" | "danger"; | ||
| 14 | readonly onPress: () => void; | ||
| 15 | }) { | ||
| 16 | const primaryBg = useThemeColor("--color-primary"); | ||
| 17 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 18 | const dangerBg = useThemeColor("--color-danger"); | ||
| 19 | const dangerBorder = useThemeColor("--color-danger-border"); | ||
| 20 | const dangerFg = useThemeColor("--color-danger-foreground"); | ||
| 21 | const secondaryBg = useThemeColor("--color-secondary"); | ||
| 22 | const secondaryBorder = useThemeColor("--color-secondary-border"); | ||
| 23 | const secondaryFg = useThemeColor("--color-secondary-foreground"); | ||
| 24 | |||
| 25 | const tone = props.tone ?? "secondary"; | ||
| 26 | const colors = | ||
| 27 | tone === "primary" | ||
| 28 | ? { | ||
| 29 | backgroundColor: primaryBg, | ||
| 30 | borderColor: "transparent", | ||
| 31 | textColor: primaryFg, | ||
| 32 | } | ||
| 33 | : tone === "danger" | ||
| 34 | ? { | ||
| 35 | backgroundColor: dangerBg, | ||
| 36 | borderColor: dangerBorder, | ||
| 37 | textColor: dangerFg, | ||
| 38 | } | ||
| 39 | : { | ||
| 40 | backgroundColor: secondaryBg, | ||
| 41 | borderColor: secondaryBorder, | ||
| 42 | textColor: secondaryFg, | ||
| 43 | }; | ||
| 44 | |||
| 45 | return ( | ||
| 46 | <Pressable | ||
| 47 | className="min-h-[48px] flex-1 flex-row items-center justify-center gap-2 rounded-[18px] px-4 py-3" | ||
| 48 | disabled={props.disabled} | ||
| 49 | style={{ | ||
| 50 | backgroundColor: colors.backgroundColor, | ||
| 51 | borderWidth: tone === "primary" ? 0 : 1, | ||
| 52 | borderColor: colors.borderColor, | ||
| 53 | opacity: props.disabled ? 0.45 : 1, | ||
| 54 | }} | ||
| 55 | onPress={props.onPress} | ||
| 56 | > | ||
| 57 | <SymbolView name={props.icon} size={16} tintColor={colors.textColor} type="monochrome" /> | ||
| 58 | <Text | ||
| 59 | className="text-[12px] font-t3-bold uppercase" | ||
| 60 | style={{ color: colors.textColor, letterSpacing: 0.9 }} | ||
| 61 | > | ||
| 62 | {props.label} | ||
| 63 | </Text> | ||
| 64 | </Pressable> | ||
| 65 | ); | ||
| 66 | } | ||
| 67 | |||
| 68 | export function MetaCard(props: { readonly label: string; readonly value: string }) { | ||
| 69 | return ( | ||
| 70 | <View className="rounded-[18px] border border-border bg-card px-4 py-3"> | ||
| 71 | <Text | ||
| 72 | className="text-foreground-muted text-[11px] font-t3-bold uppercase" | ||
| 73 | style={{ letterSpacing: 0.9 }} | ||
| 74 | > | ||
| 75 | {props.label} | ||
| 76 | </Text> | ||
| 77 | <Text selectable className="text-foreground text-[13px] font-medium" numberOfLines={1}> | ||
| 78 | {props.value} | ||
| 79 | </Text> | ||
| 80 | </View> | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | |||
| 84 | export function SheetListRow(props: { | ||
| 85 | readonly icon: ComponentProps<typeof SymbolView>["name"]; | ||
| 86 | readonly title: string; | ||
| 87 | readonly subtitle?: string | null; | ||
| 88 | readonly disabled?: boolean; | ||
| 89 | readonly onPress: () => void; | ||
| 90 | }) { | ||
| 91 | const iconColor = useThemeColor("--color-icon"); | ||
| 92 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | ||
| 93 | |||
| 94 | return ( | ||
| 95 | <Pressable | ||
| 96 | className="flex-row items-center gap-3 px-1 py-3" | ||
| 97 | disabled={props.disabled} | ||
| 98 | style={{ opacity: props.disabled ? 0.45 : 1 }} | ||
| 99 | onPress={props.onPress} | ||
| 100 | > | ||
| 101 | <View className="bg-subtle h-9 w-9 items-center justify-center rounded-full"> | ||
| 102 | <SymbolView name={props.icon} size={16} tintColor={iconColor} type="monochrome" /> | ||
| 103 | </View> | ||
| 104 | <View className="flex-1 gap-0.5"> | ||
| 105 | <Text className="text-foreground text-[16px] font-t3-bold">{props.title}</Text> | ||
| 106 | {props.subtitle ? ( | ||
| 107 | <Text className="text-foreground-muted text-[12px] leading-[17px]">{props.subtitle}</Text> | ||
| 108 | ) : null} | ||
| 109 | </View> | ||
| 110 | <SymbolView name="chevron.right" size={13} tintColor={iconSubtleColor} type="monochrome" /> | ||
| 111 | </Pressable> | ||
| 112 | ); | ||
| 113 | } | ||
| 114 | |||
| 115 | /* ─── Shared utilities ──────────────────────────────────────────────── */ | ||
| 116 | |||
| 117 | export function menuItemIconName( | ||
| 118 | icon: "commit" | "push" | "pr", | ||
| 119 | ): ComponentProps<typeof SymbolView>["name"] { | ||
| 120 | if (icon === "commit") return "checkmark.circle"; | ||
| 121 | if (icon === "push") return "arrow.up.circle"; | ||
| 122 | return "arrow.up.right.circle"; | ||
| 123 | } | ||
| 124 | |||
| 125 | export function statusSummary( | ||
| 126 | gitStatus: { | ||
| 127 | readonly isRepo?: boolean; | ||
| 128 | readonly hasWorkingTreeChanges?: boolean; | ||
| 129 | readonly workingTree?: { readonly files: readonly { readonly path: string }[] }; | ||
| 130 | readonly aheadCount?: number; | ||
| 131 | readonly behindCount?: number; | ||
| 132 | readonly pr?: { readonly state?: string; readonly number?: number } | null; | ||
| 133 | } | null, | ||
| 134 | ): string { | ||
| 135 | if (!gitStatus) { | ||
| 136 | return "Loading branch status\u2026"; | ||
| 137 | } | ||
| 138 | |||
| 139 | if (!gitStatus.isRepo) { | ||
| 140 | return "Not a git repository"; | ||
| 141 | } | ||
| 142 | |||
| 143 | const parts: string[] = []; | ||
| 144 | if (gitStatus.hasWorkingTreeChanges) { | ||
| 145 | const fileCount = gitStatus.workingTree?.files.length ?? 0; | ||
| 146 | parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"} changed`); | ||
| 147 | } else { | ||
| 148 | parts.push("Clean"); | ||
| 149 | } | ||
| 150 | if ((gitStatus.aheadCount ?? 0) > 0) { | ||
| 151 | parts.push(`${gitStatus.aheadCount} ahead`); | ||
| 152 | } | ||
| 153 | if ((gitStatus.behindCount ?? 0) > 0) { | ||
| 154 | parts.push(`${gitStatus.behindCount} behind`); | ||
| 155 | } | ||
| 156 | if (gitStatus.pr?.state === "open") { | ||
| 157 | parts.push(`PR #${gitStatus.pr.number} open`); | ||
| 158 | } | ||
| 159 | |||
| 160 | return parts.join(" \u00b7 "); | ||
| 161 | } | ||
apps/mobile/src/features/threads/new-task-flow-provider.tsx created+513| ... | @@ -0,0 +1,513 @@ | ||
| 1 | import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 2 | |||
| 3 | import type { | ||
| 4 | EnvironmentId, | ||
| 5 | ModelSelection, | ||
| 6 | ProviderInteractionMode, | ||
| 7 | RuntimeMode, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; | ||
| 10 | import * as Arr from "effect/Array"; | ||
| 11 | import { pipe } from "effect/Function"; | ||
| 12 | |||
| 13 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | ||
| 14 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | ||
| 15 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | ||
| 16 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | ||
| 17 | import { scopedProjectKey } from "../../lib/scopedEntities"; | ||
| 18 | import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs"; | ||
| 19 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | ||
| 20 | import { | ||
| 21 | setPendingConnectionError, | ||
| 22 | useRemoteEnvironmentState, | ||
| 23 | } from "../../state/use-remote-environment-registry"; | ||
| 24 | import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; | ||
| 25 | import type { ClaudeAgentEffort } from "./claudeEffortOptions"; | ||
| 26 | |||
| 27 | type WorkspaceMode = "local" | "worktree"; | ||
| 28 | |||
| 29 | function normalizeSelectedWorktreePath( | ||
| 30 | project: EnvironmentScopedProjectShell, | ||
| 31 | branch: VcsRef, | ||
| 32 | ): string | null { | ||
| 33 | if (!branch.worktreePath) { | ||
| 34 | return null; | ||
| 35 | } | ||
| 36 | |||
| 37 | return branch.worktreePath === project.workspaceRoot ? null : branch.worktreePath; | ||
| 38 | } | ||
| 39 | |||
| 40 | export function branchBadgeLabel(input: { | ||
| 41 | readonly branch: VcsRef; | ||
| 42 | readonly project: EnvironmentScopedProjectShell | null; | ||
| 43 | }): string | null { | ||
| 44 | if (input.branch.current) { | ||
| 45 | return "current"; | ||
| 46 | } | ||
| 47 | if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) { | ||
| 48 | return "worktree"; | ||
| 49 | } | ||
| 50 | if (input.branch.isDefault) { | ||
| 51 | return "default"; | ||
| 52 | } | ||
| 53 | if (input.branch.isRemote) { | ||
| 54 | return "remote"; | ||
| 55 | } | ||
| 56 | return null; | ||
| 57 | } | ||
| 58 | |||
| 59 | type NewTaskFlowContextValue = { | ||
| 60 | readonly logicalProjects: ReadonlyArray<{ | ||
| 61 | readonly key: string; | ||
| 62 | readonly project: EnvironmentScopedProjectShell; | ||
| 63 | }>; | ||
| 64 | readonly selectedEnvironmentId: EnvironmentId | null; | ||
| 65 | readonly selectedProjectKey: string | null; | ||
| 66 | readonly selectedModelKey: string | null; | ||
| 67 | readonly workspaceMode: WorkspaceMode; | ||
| 68 | readonly selectedBranchName: string | null; | ||
| 69 | readonly selectedWorktreePath: string | null; | ||
| 70 | readonly prompt: string; | ||
| 71 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 72 | readonly submitting: boolean; | ||
| 73 | readonly branchQuery: string; | ||
| 74 | readonly branchesLoading: boolean; | ||
| 75 | readonly availableBranches: ReadonlyArray<VcsRef>; | ||
| 76 | readonly runtimeMode: RuntimeMode; | ||
| 77 | readonly interactionMode: ProviderInteractionMode; | ||
| 78 | readonly effort: ClaudeAgentEffort; | ||
| 79 | readonly fastMode: boolean; | ||
| 80 | readonly contextWindow: string; | ||
| 81 | readonly expandedProvider: string | null; | ||
| 82 | readonly environments: ReadonlyArray<{ | ||
| 83 | readonly environmentId: EnvironmentId; | ||
| 84 | readonly environmentLabel: string; | ||
| 85 | }>; | ||
| 86 | readonly selectedProject: EnvironmentScopedProjectShell | null; | ||
| 87 | readonly modelOptions: ReadonlyArray<ModelOption>; | ||
| 88 | readonly selectedModel: ModelSelection | null; | ||
| 89 | readonly selectedModelOption: ModelOption | null; | ||
| 90 | readonly providerGroups: ReadonlyArray<ProviderGroup>; | ||
| 91 | readonly filteredBranches: ReadonlyArray<VcsRef>; | ||
| 92 | readonly reset: () => void; | ||
| 93 | readonly setProject: (project: EnvironmentScopedProjectShell) => void; | ||
| 94 | readonly selectEnvironment: (environmentId: EnvironmentId) => void; | ||
| 95 | readonly setSelectedModelKey: (key: string | null) => void; | ||
| 96 | readonly setWorkspaceMode: (mode: WorkspaceMode) => void; | ||
| 97 | readonly selectBranch: (branch: VcsRef) => void; | ||
| 98 | readonly setPrompt: (value: string) => void; | ||
| 99 | readonly replaceAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; | ||
| 100 | readonly appendAttachments: (attachments: ReadonlyArray<DraftComposerImageAttachment>) => void; | ||
| 101 | readonly removeAttachment: (imageId: string) => void; | ||
| 102 | readonly clearAttachments: () => void; | ||
| 103 | readonly setSubmitting: (value: boolean) => void; | ||
| 104 | readonly setBranchQuery: (value: string) => void; | ||
| 105 | readonly loadBranches: () => Promise<void>; | ||
| 106 | readonly setRuntimeMode: (value: RuntimeMode) => void; | ||
| 107 | readonly setInteractionMode: (value: ProviderInteractionMode) => void; | ||
| 108 | readonly setEffort: (value: ClaudeAgentEffort) => void; | ||
| 109 | readonly setFastMode: (value: boolean) => void; | ||
| 110 | readonly setContextWindow: (value: string) => void; | ||
| 111 | readonly setExpandedProvider: (value: string | null) => void; | ||
| 112 | }; | ||
| 113 | |||
| 114 | const NewTaskFlowContext = React.createContext<NewTaskFlowContextValue | null>(null); | ||
| 115 | |||
| 116 | export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ||
| 117 | const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog(); | ||
| 118 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 119 | |||
| 120 | const repositoryGroups = useMemo( | ||
| 121 | () => groupProjectsByRepository({ projects, threads }), | ||
| 122 | [projects, threads], | ||
| 123 | ); | ||
| 124 | const logicalProjects = useMemo( | ||
| 125 | () => | ||
| 126 | pipe( | ||
| 127 | repositoryGroups, | ||
| 128 | Arr.map((group) => { | ||
| 129 | const primaryProject = group.projects[0]?.project; | ||
| 130 | if (!primaryProject) { | ||
| 131 | return null; | ||
| 132 | } | ||
| 133 | return { key: group.key, project: primaryProject }; | ||
| 134 | }), | ||
| 135 | Arr.filter( | ||
| 136 | ( | ||
| 137 | entry, | ||
| 138 | ): entry is { | ||
| 139 | readonly key: string; | ||
| 140 | readonly project: EnvironmentScopedProjectShell; | ||
| 141 | } => entry !== null, | ||
| 142 | ), | ||
| 143 | ), | ||
| 144 | [repositoryGroups], | ||
| 145 | ); | ||
| 146 | |||
| 147 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>( | ||
| 148 | projects[0]?.environmentId ?? null, | ||
| 149 | ); | ||
| 150 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); | ||
| 151 | const [selectedModelKey, setSelectedModelKey] = useState<string | null>(null); | ||
| 152 | const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("local"); | ||
| 153 | const [selectedBranchName, setSelectedBranchName] = useState<string | null>(null); | ||
| 154 | const [selectedWorktreePath, setSelectedWorktreePath] = useState<string | null>(null); | ||
| 155 | const branchLoadVersionRef = useRef(0); | ||
| 156 | const [prompt, setPrompt] = useState(""); | ||
| 157 | const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); | ||
| 158 | const [submitting, setSubmitting] = useState(false); | ||
| 159 | const [branchQuery, setBranchQuery] = useState(""); | ||
| 160 | const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE); | ||
| 161 | const [interactionMode, setInteractionMode] = useState<ProviderInteractionMode>( | ||
| 162 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 163 | ); | ||
| 164 | const [effort, setEffort] = useState<ClaudeAgentEffort>("high"); | ||
| 165 | const [fastMode, setFastMode] = useState(false); | ||
| 166 | const [contextWindow, setContextWindow] = useState("1M"); | ||
| 167 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); | ||
| 168 | |||
| 169 | const replaceAttachments = useCallback( | ||
| 170 | (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => { | ||
| 171 | setAttachments(nextAttachments); | ||
| 172 | }, | ||
| 173 | [], | ||
| 174 | ); | ||
| 175 | |||
| 176 | const appendAttachments = useCallback( | ||
| 177 | (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => { | ||
| 178 | setAttachments((current) => [...current, ...nextAttachments]); | ||
| 179 | }, | ||
| 180 | [], | ||
| 181 | ); | ||
| 182 | |||
| 183 | const removeAttachment = useCallback((imageId: string) => { | ||
| 184 | setAttachments((current) => current.filter((candidate) => candidate.id !== imageId)); | ||
| 185 | }, []); | ||
| 186 | |||
| 187 | const clearAttachments = useCallback(() => { | ||
| 188 | setAttachments([]); | ||
| 189 | }, []); | ||
| 190 | |||
| 191 | const reset = useCallback(() => { | ||
| 192 | console.log("[new task flow] reset", { | ||
| 193 | defaultEnvironmentId: projects[0]?.environmentId ?? null, | ||
| 194 | projectCount: projects.length, | ||
| 195 | }); | ||
| 196 | setSelectedEnvironmentId(projects[0]?.environmentId ?? null); | ||
| 197 | setSelectedProjectKey(null); | ||
| 198 | setSelectedModelKey(null); | ||
| 199 | setWorkspaceMode("local"); | ||
| 200 | setSelectedBranchName(null); | ||
| 201 | setSelectedWorktreePath(null); | ||
| 202 | setPrompt(""); | ||
| 203 | clearAttachments(); | ||
| 204 | setSubmitting(false); | ||
| 205 | setBranchQuery(""); | ||
| 206 | setRuntimeMode(DEFAULT_RUNTIME_MODE); | ||
| 207 | setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); | ||
| 208 | setEffort("high"); | ||
| 209 | setFastMode(false); | ||
| 210 | setContextWindow("1M"); | ||
| 211 | setExpandedProvider(null); | ||
| 212 | }, [clearAttachments, projects]); | ||
| 213 | |||
| 214 | useEffect(() => { | ||
| 215 | if (selectedEnvironmentId !== null || projects.length === 0) { | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | |||
| 219 | console.log("[new task flow] initializing environment", { | ||
| 220 | environmentId: projects[0]!.environmentId, | ||
| 221 | }); | ||
| 222 | setSelectedEnvironmentId(projects[0]!.environmentId); | ||
| 223 | }, [projects, selectedEnvironmentId]); | ||
| 224 | |||
| 225 | const environments = useMemo( | ||
| 226 | () => | ||
| 227 | pipe( | ||
| 228 | [ | ||
| 229 | ...new Set( | ||
| 230 | pipe( | ||
| 231 | projects, | ||
| 232 | Arr.map((project) => project.environmentId), | ||
| 233 | ), | ||
| 234 | ), | ||
| 235 | ], | ||
| 236 | Arr.map((environmentId) => { | ||
| 237 | const environment = savedConnectionsById[environmentId]; | ||
| 238 | if (!environment) { | ||
| 239 | return null; | ||
| 240 | } | ||
| 241 | |||
| 242 | return { | ||
| 243 | environmentId, | ||
| 244 | environmentLabel: environment.environmentLabel, | ||
| 245 | }; | ||
| 246 | }), | ||
| 247 | Arr.filter( | ||
| 248 | ( | ||
| 249 | entry, | ||
| 250 | ): entry is { | ||
| 251 | readonly environmentId: EnvironmentId; | ||
| 252 | readonly environmentLabel: string; | ||
| 253 | } => entry !== null, | ||
| 254 | ), | ||
| 255 | ), | ||
| 256 | [projects, savedConnectionsById], | ||
| 257 | ); | ||
| 258 | |||
| 259 | const projectsForEnvironment = useMemo( | ||
| 260 | () => | ||
| 261 | pipe( | ||
| 262 | projects, | ||
| 263 | Arr.filter((project) => project.environmentId === selectedEnvironmentId), | ||
| 264 | ), | ||
| 265 | [projects, selectedEnvironmentId], | ||
| 266 | ); | ||
| 267 | |||
| 268 | const selectedProject = | ||
| 269 | projectsForEnvironment.find( | ||
| 270 | (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, | ||
| 271 | ) ?? | ||
| 272 | projectsForEnvironment[0] ?? | ||
| 273 | null; | ||
| 274 | |||
| 275 | const modelOptions = useMemo( | ||
| 276 | () => | ||
| 277 | buildModelOptions( | ||
| 278 | selectedProject | ||
| 279 | ? (serverConfigByEnvironmentId[selectedProject.environmentId] ?? null) | ||
| 280 | : null, | ||
| 281 | selectedProject?.defaultModelSelection ?? null, | ||
| 282 | ), | ||
| 283 | [selectedProject, serverConfigByEnvironmentId], | ||
| 284 | ); | ||
| 285 | |||
| 286 | const selectedModel = | ||
| 287 | modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? | ||
| 288 | selectedProject?.defaultModelSelection ?? | ||
| 289 | modelOptions[0]?.selection ?? | ||
| 290 | null; | ||
| 291 | |||
| 292 | const selectedModelOption = | ||
| 293 | modelOptions.find( | ||
| 294 | (option) => | ||
| 295 | selectedModel && | ||
| 296 | option.selection.instanceId === selectedModel.instanceId && | ||
| 297 | option.selection.model === selectedModel.model, | ||
| 298 | ) ?? null; | ||
| 299 | |||
| 300 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); | ||
| 301 | const branchTarget = useMemo( | ||
| 302 | () => ({ | ||
| 303 | environmentId: selectedProject?.environmentId ?? null, | ||
| 304 | cwd: selectedProject?.workspaceRoot ?? null, | ||
| 305 | query: null, | ||
| 306 | }), | ||
| 307 | [selectedProject?.environmentId, selectedProject?.workspaceRoot], | ||
| 308 | ); | ||
| 309 | const branchState = useVcsRefs(branchTarget); | ||
| 310 | const branchesLoading = branchState.isPending; | ||
| 311 | const availableBranches = useMemo( | ||
| 312 | () => | ||
| 313 | pipe( | ||
| 314 | branchState.data?.refs ?? [], | ||
| 315 | Arr.filter((branch) => !branch.isRemote), | ||
| 316 | ), | ||
| 317 | [branchState.data?.refs], | ||
| 318 | ); | ||
| 319 | |||
| 320 | const filteredBranches = useMemo(() => { | ||
| 321 | const query = branchQuery.trim().toLowerCase(); | ||
| 322 | if (query.length === 0) { | ||
| 323 | return availableBranches; | ||
| 324 | } | ||
| 325 | |||
| 326 | return pipe( | ||
| 327 | availableBranches, | ||
| 328 | Arr.filter((branch) => branch.name.toLowerCase().includes(query)), | ||
| 329 | ); | ||
| 330 | }, [availableBranches, branchQuery]); | ||
| 331 | |||
| 332 | const setProject = useCallback((project: EnvironmentScopedProjectShell) => { | ||
| 333 | const nextProjectKey = scopedProjectKey(project.environmentId, project.id); | ||
| 334 | branchLoadVersionRef.current += 1; | ||
| 335 | setSelectedEnvironmentId(project.environmentId); | ||
| 336 | setSelectedProjectKey(nextProjectKey); | ||
| 337 | setSelectedBranchName(null); | ||
| 338 | setSelectedWorktreePath(null); | ||
| 339 | }, []); | ||
| 340 | |||
| 341 | const selectEnvironment = useCallback((environmentId: EnvironmentId) => { | ||
| 342 | branchLoadVersionRef.current += 1; | ||
| 343 | setSelectedEnvironmentId(environmentId); | ||
| 344 | setSelectedProjectKey(null); | ||
| 345 | setSelectedBranchName(null); | ||
| 346 | setSelectedWorktreePath(null); | ||
| 347 | }, []); | ||
| 348 | |||
| 349 | const selectBranch = useCallback( | ||
| 350 | (branch: VcsRef) => { | ||
| 351 | setSelectedBranchName(branch.name); | ||
| 352 | setSelectedWorktreePath( | ||
| 353 | selectedProject ? normalizeSelectedWorktreePath(selectedProject, branch) : null, | ||
| 354 | ); | ||
| 355 | }, | ||
| 356 | [selectedProject], | ||
| 357 | ); | ||
| 358 | |||
| 359 | const loadBranches = useCallback(async () => { | ||
| 360 | if (!selectedProject) { | ||
| 361 | return; | ||
| 362 | } | ||
| 363 | |||
| 364 | const loadVersion = ++branchLoadVersionRef.current; | ||
| 365 | const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id); | ||
| 366 | try { | ||
| 367 | const result = await vcsRefManager.load({ | ||
| 368 | environmentId: selectedProject.environmentId, | ||
| 369 | cwd: selectedProject.workspaceRoot, | ||
| 370 | query: null, | ||
| 371 | }); | ||
| 372 | if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { | ||
| 373 | return; | ||
| 374 | } | ||
| 375 | setPendingConnectionError(null); | ||
| 376 | const branches = pipe( | ||
| 377 | result?.refs ?? [], | ||
| 378 | Arr.filter((branch) => !branch.isRemote), | ||
| 379 | ); | ||
| 380 | |||
| 381 | if (workspaceMode === "worktree" && !selectedBranchName) { | ||
| 382 | const preferredBranch = | ||
| 383 | branches.find((branch) => branch.current)?.name ?? | ||
| 384 | branches.find((branch) => branch.isDefault)?.name ?? | ||
| 385 | null; | ||
| 386 | if (preferredBranch) { | ||
| 387 | setSelectedBranchName(preferredBranch); | ||
| 388 | } | ||
| 389 | } | ||
| 390 | } catch { | ||
| 391 | if (loadVersion !== branchLoadVersionRef.current) { | ||
| 392 | return; | ||
| 393 | } | ||
| 394 | setPendingConnectionError("Failed to load branches."); | ||
| 395 | } | ||
| 396 | }, [selectedBranchName, selectedProject, selectedProjectKey, workspaceMode]); | ||
| 397 | |||
| 398 | const value = useMemo<NewTaskFlowContextValue>( | ||
| 399 | () => ({ | ||
| 400 | logicalProjects, | ||
| 401 | selectedEnvironmentId, | ||
| 402 | selectedProjectKey, | ||
| 403 | selectedModelKey, | ||
| 404 | workspaceMode, | ||
| 405 | selectedBranchName, | ||
| 406 | selectedWorktreePath, | ||
| 407 | prompt, | ||
| 408 | attachments, | ||
| 409 | submitting, | ||
| 410 | branchQuery, | ||
| 411 | branchesLoading, | ||
| 412 | availableBranches, | ||
| 413 | runtimeMode, | ||
| 414 | interactionMode, | ||
| 415 | effort, | ||
| 416 | fastMode, | ||
| 417 | contextWindow, | ||
| 418 | expandedProvider, | ||
| 419 | environments, | ||
| 420 | selectedProject, | ||
| 421 | modelOptions, | ||
| 422 | selectedModel, | ||
| 423 | selectedModelOption, | ||
| 424 | providerGroups, | ||
| 425 | filteredBranches, | ||
| 426 | reset, | ||
| 427 | setProject, | ||
| 428 | selectEnvironment, | ||
| 429 | setSelectedModelKey, | ||
| 430 | setWorkspaceMode, | ||
| 431 | selectBranch, | ||
| 432 | setPrompt, | ||
| 433 | replaceAttachments, | ||
| 434 | appendAttachments, | ||
| 435 | removeAttachment, | ||
| 436 | clearAttachments, | ||
| 437 | setSubmitting, | ||
| 438 | setBranchQuery, | ||
| 439 | loadBranches, | ||
| 440 | setRuntimeMode, | ||
| 441 | setInteractionMode, | ||
| 442 | setEffort, | ||
| 443 | setFastMode, | ||
| 444 | setContextWindow, | ||
| 445 | setExpandedProvider, | ||
| 446 | }), | ||
| 447 | [ | ||
| 448 | attachments, | ||
| 449 | availableBranches, | ||
| 450 | branchQuery, | ||
| 451 | branchesLoading, | ||
| 452 | contextWindow, | ||
| 453 | effort, | ||
| 454 | environments, | ||
| 455 | expandedProvider, | ||
| 456 | fastMode, | ||
| 457 | filteredBranches, | ||
| 458 | interactionMode, | ||
| 459 | loadBranches, | ||
| 460 | logicalProjects, | ||
| 461 | modelOptions, | ||
| 462 | prompt, | ||
| 463 | providerGroups, | ||
| 464 | replaceAttachments, | ||
| 465 | reset, | ||
| 466 | runtimeMode, | ||
| 467 | selectedBranchName, | ||
| 468 | selectedEnvironmentId, | ||
| 469 | selectedModel, | ||
| 470 | selectedModelKey, | ||
| 471 | selectedModelOption, | ||
| 472 | selectedProject, | ||
| 473 | selectedProjectKey, | ||
| 474 | selectedWorktreePath, | ||
| 475 | setProject, | ||
| 476 | selectBranch, | ||
| 477 | selectEnvironment, | ||
| 478 | submitting, | ||
| 479 | workspaceMode, | ||
| 480 | appendAttachments, | ||
| 481 | clearAttachments, | ||
| 482 | removeAttachment, | ||
| 483 | ], | ||
| 484 | ); | ||
| 485 | |||
| 486 | useEffect(() => { | ||
| 487 | console.log("[new task flow] state", { | ||
| 488 | availableBranchCount: availableBranches.length, | ||
| 489 | environmentCount: environments.length, | ||
| 490 | logicalProjectCount: logicalProjects.length, | ||
| 491 | selectedEnvironmentId, | ||
| 492 | selectedProjectKey, | ||
| 493 | selectedProjectTitle: selectedProject?.title ?? null, | ||
| 494 | }); | ||
| 495 | }, [ | ||
| 496 | availableBranches.length, | ||
| 497 | environments.length, | ||
| 498 | logicalProjects.length, | ||
| 499 | selectedEnvironmentId, | ||
| 500 | selectedProject?.title, | ||
| 501 | selectedProjectKey, | ||
| 502 | ]); | ||
| 503 | |||
| 504 | return <NewTaskFlowContext.Provider value={value}>{props.children}</NewTaskFlowContext.Provider>; | ||
| 505 | } | ||
| 506 | |||
| 507 | export function useNewTaskFlow() { | ||
| 508 | const value = React.use(NewTaskFlowContext); | ||
| 509 | if (value === null) { | ||
| 510 | throw new Error("useNewTaskFlow must be used within NewTaskFlowProvider."); | ||
| 511 | } | ||
| 512 | return value; | ||
| 513 | } | ||
apps/mobile/src/features/threads/threadPresentation.ts created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | import type { StatusTone } from "../../components/StatusPill"; | ||
| 2 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; | ||
| 3 | |||
| 4 | export function threadSortValue(thread: EnvironmentScopedThreadShell): number { | ||
| 5 | const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); | ||
| 6 | return Number.isNaN(candidate) ? 0 : candidate; | ||
| 7 | } | ||
| 8 | |||
| 9 | export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTone { | ||
| 10 | const status = thread.session?.status; | ||
| 11 | if (status === "running") { | ||
| 12 | return { | ||
| 13 | label: "Running", | ||
| 14 | pillClassName: "bg-orange-500/12 dark:bg-orange-500/16", | ||
| 15 | textClassName: "text-orange-700 dark:text-orange-300", | ||
| 16 | }; | ||
| 17 | } | ||
| 18 | if (status === "ready") { | ||
| 19 | return { | ||
| 20 | label: "Ready", | ||
| 21 | pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", | ||
| 22 | textClassName: "text-emerald-700 dark:text-emerald-300", | ||
| 23 | }; | ||
| 24 | } | ||
| 25 | if (status === "starting") { | ||
| 26 | return { | ||
| 27 | label: "Starting", | ||
| 28 | pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", | ||
| 29 | textClassName: "text-sky-700 dark:text-sky-300", | ||
| 30 | }; | ||
| 31 | } | ||
| 32 | if (status === "error") { | ||
| 33 | return { | ||
| 34 | label: "Error", | ||
| 35 | pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", | ||
| 36 | textClassName: "text-rose-700 dark:text-rose-300", | ||
| 37 | }; | ||
| 38 | } | ||
| 39 | return { | ||
| 40 | label: "Idle", | ||
| 41 | pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", | ||
| 42 | textClassName: "text-neutral-600 dark:text-neutral-300", | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 46 | export function messageImageUrl(httpBaseUrl: string | null, attachmentId: string): string | null { | ||
| 47 | if (!httpBaseUrl) { | ||
| 48 | return null; | ||
| 49 | } | ||
| 50 | |||
| 51 | const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl); | ||
| 52 | return url.toString(); | ||
| 53 | } | ||
apps/mobile/src/features/threads/use-project-actions.ts created+262| ... | @@ -0,0 +1,262 @@ | ||
| 1 | import { useCallback } from "react"; | ||
| 2 | |||
| 3 | import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; | ||
| 4 | import { | ||
| 5 | CommandId, | ||
| 6 | DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 7 | DEFAULT_RUNTIME_MODE, | ||
| 8 | type EnvironmentId, | ||
| 9 | MessageId, | ||
| 10 | ThreadId, | ||
| 11 | type ModelSelection, | ||
| 12 | type ProviderInteractionMode, | ||
| 13 | type RuntimeMode, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import { buildTemporaryWorktreeBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 16 | import { uuidv4 } from "../../lib/uuid"; | ||
| 17 | |||
| 18 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | ||
| 19 | import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; | ||
| 20 | import { getEnvironmentClient } from "../../state/environment-session-registry"; | ||
| 21 | import { environmentRuntimeManager } from "../../state/use-environment-runtime"; | ||
| 22 | import { vcsRefManager } from "../../state/use-vcs-refs"; | ||
| 23 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | ||
| 24 | import { | ||
| 25 | setPendingConnectionError, | ||
| 26 | useRemoteEnvironmentState, | ||
| 27 | } from "../../state/use-remote-environment-registry"; | ||
| 28 | |||
| 29 | function useRefreshRemoteData() { | ||
| 30 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 31 | |||
| 32 | return useCallback( | ||
| 33 | async (environmentIds?: ReadonlyArray<EnvironmentId>) => { | ||
| 34 | const targets = | ||
| 35 | environmentIds ?? | ||
| 36 | Object.values(savedConnectionsById).map((connection) => connection.environmentId); | ||
| 37 | |||
| 38 | await Promise.all( | ||
| 39 | targets.map(async (environmentId) => { | ||
| 40 | const client = getEnvironmentClient(environmentId); | ||
| 41 | if (!client) { | ||
| 42 | return; | ||
| 43 | } | ||
| 44 | |||
| 45 | try { | ||
| 46 | const serverConfig = await client.server.getConfig(); | ||
| 47 | environmentRuntimeManager.patch({ environmentId }, (current) => ({ | ||
| 48 | ...current, | ||
| 49 | serverConfig, | ||
| 50 | connectionError: null, | ||
| 51 | })); | ||
| 52 | } catch (error) { | ||
| 53 | environmentRuntimeManager.patch({ environmentId }, (current) => ({ | ||
| 54 | ...current, | ||
| 55 | connectionError: | ||
| 56 | error instanceof Error ? error.message : "Failed to refresh remote data.", | ||
| 57 | })); | ||
| 58 | } | ||
| 59 | }), | ||
| 60 | ); | ||
| 61 | }, | ||
| 62 | [savedConnectionsById], | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | |||
| 66 | function deriveThreadTitleFromPrompt(value: string): string { | ||
| 67 | const trimmed = value.trim(); | ||
| 68 | if (trimmed.length === 0) { | ||
| 69 | return "New thread"; | ||
| 70 | } | ||
| 71 | |||
| 72 | const compact = trimmed.replace(/\s+/g, " "); | ||
| 73 | return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`; | ||
| 74 | } | ||
| 75 | |||
| 76 | export function useProjectActions() { | ||
| 77 | const { threads } = useRemoteCatalog(); | ||
| 78 | const refreshRemoteData = useRefreshRemoteData(); | ||
| 79 | |||
| 80 | const onCreateThreadWithOptions = useCallback( | ||
| 81 | async (input: { | ||
| 82 | readonly project: EnvironmentScopedProjectShell; | ||
| 83 | readonly modelSelection: ModelSelection; | ||
| 84 | readonly envMode: "local" | "worktree"; | ||
| 85 | readonly branch: string | null; | ||
| 86 | readonly worktreePath: string | null; | ||
| 87 | readonly runtimeMode: RuntimeMode; | ||
| 88 | readonly interactionMode: ProviderInteractionMode; | ||
| 89 | readonly initialMessageText: string; | ||
| 90 | readonly initialAttachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 91 | }) => { | ||
| 92 | const client = getEnvironmentClient(input.project.environmentId); | ||
| 93 | if (!client) { | ||
| 94 | return null; | ||
| 95 | } | ||
| 96 | |||
| 97 | const metadata = makeTurnCommandMetadata(); | ||
| 98 | const threadId = ThreadId.make(metadata.threadId); | ||
| 99 | const createdAt = metadata.createdAt; | ||
| 100 | const initialMessageText = input.initialMessageText.trim(); | ||
| 101 | const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); | ||
| 102 | |||
| 103 | if (initialMessageText.length === 0) { | ||
| 104 | return null; | ||
| 105 | } | ||
| 106 | if (input.envMode === "worktree" && !input.branch) { | ||
| 107 | return null; | ||
| 108 | } | ||
| 109 | |||
| 110 | const isWorktree = input.envMode === "worktree"; | ||
| 111 | |||
| 112 | await client.orchestration.dispatchCommand({ | ||
| 113 | type: "thread.turn.start", | ||
| 114 | commandId: CommandId.make(metadata.commandId), | ||
| 115 | threadId, | ||
| 116 | message: { | ||
| 117 | messageId: MessageId.make(metadata.messageId), | ||
| 118 | role: "user", | ||
| 119 | text: initialMessageText, | ||
| 120 | attachments: input.initialAttachments, | ||
| 121 | }, | ||
| 122 | modelSelection: input.modelSelection, | ||
| 123 | titleSeed: nextTitle, | ||
| 124 | runtimeMode: input.runtimeMode, | ||
| 125 | interactionMode: input.interactionMode, | ||
| 126 | bootstrap: { | ||
| 127 | createThread: { | ||
| 128 | projectId: input.project.id, | ||
| 129 | title: nextTitle, | ||
| 130 | modelSelection: input.modelSelection, | ||
| 131 | runtimeMode: input.runtimeMode, | ||
| 132 | interactionMode: input.interactionMode, | ||
| 133 | branch: input.branch, | ||
| 134 | worktreePath: isWorktree ? null : input.worktreePath, | ||
| 135 | createdAt, | ||
| 136 | }, | ||
| 137 | ...(isWorktree | ||
| 138 | ? { | ||
| 139 | prepareWorktree: { | ||
| 140 | projectCwd: input.project.workspaceRoot, | ||
| 141 | baseBranch: input.branch!, | ||
| 142 | branch: buildTemporaryWorktreeBranchName(uuidv4), | ||
| 143 | }, | ||
| 144 | runSetupScript: true, | ||
| 145 | } | ||
| 146 | : {}), | ||
| 147 | }, | ||
| 148 | createdAt, | ||
| 149 | }); | ||
| 150 | |||
| 151 | await refreshRemoteData([input.project.environmentId]); | ||
| 152 | return { | ||
| 153 | environmentId: input.project.environmentId, | ||
| 154 | threadId, | ||
| 155 | }; | ||
| 156 | }, | ||
| 157 | [refreshRemoteData], | ||
| 158 | ); | ||
| 159 | |||
| 160 | const onCreateThread = useCallback( | ||
| 161 | async (project: EnvironmentScopedProjectShell) => { | ||
| 162 | const latestProjectThread = | ||
| 163 | threads.find( | ||
| 164 | (thread) => | ||
| 165 | thread.environmentId === project.environmentId && thread.projectId === project.id, | ||
| 166 | ) ?? null; | ||
| 167 | const modelSelection = | ||
| 168 | project.defaultModelSelection ?? latestProjectThread?.modelSelection ?? null; | ||
| 169 | if (!modelSelection) { | ||
| 170 | setPendingConnectionError("This project does not have a default model configured yet."); | ||
| 171 | return null; | ||
| 172 | } | ||
| 173 | |||
| 174 | return await onCreateThreadWithOptions({ | ||
| 175 | project, | ||
| 176 | modelSelection, | ||
| 177 | envMode: "local", | ||
| 178 | branch: null, | ||
| 179 | worktreePath: null, | ||
| 180 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 181 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 182 | initialMessageText: "", | ||
| 183 | initialAttachments: [], | ||
| 184 | }); | ||
| 185 | }, | ||
| 186 | [onCreateThreadWithOptions, threads], | ||
| 187 | ); | ||
| 188 | |||
| 189 | const onListProjectBranches = useCallback( | ||
| 190 | async (project: EnvironmentScopedProjectShell): Promise<ReadonlyArray<VcsRef>> => { | ||
| 191 | const client = getEnvironmentClient(project.environmentId); | ||
| 192 | if (!client) { | ||
| 193 | return []; | ||
| 194 | } | ||
| 195 | |||
| 196 | try { | ||
| 197 | const result = await vcsRefManager.load( | ||
| 198 | { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null }, | ||
| 199 | client.vcs, | ||
| 200 | { limit: 100 }, | ||
| 201 | ); | ||
| 202 | return (result?.refs ?? []).filter((branch) => !branch.isRemote); | ||
| 203 | } catch (error) { | ||
| 204 | setPendingConnectionError( | ||
| 205 | error instanceof Error ? error.message : "Failed to load branches.", | ||
| 206 | ); | ||
| 207 | return []; | ||
| 208 | } | ||
| 209 | }, | ||
| 210 | [], | ||
| 211 | ); | ||
| 212 | |||
| 213 | const onCreateProjectWorktree = useCallback( | ||
| 214 | async ( | ||
| 215 | project: EnvironmentScopedProjectShell, | ||
| 216 | nextWorktree: { | ||
| 217 | readonly baseBranch: string; | ||
| 218 | readonly newBranch: string; | ||
| 219 | }, | ||
| 220 | ): Promise<{ | ||
| 221 | readonly branch: string; | ||
| 222 | readonly worktreePath: string; | ||
| 223 | } | null> => { | ||
| 224 | const client = getEnvironmentClient(project.environmentId); | ||
| 225 | if (!client) { | ||
| 226 | return null; | ||
| 227 | } | ||
| 228 | |||
| 229 | try { | ||
| 230 | const result = await client.vcs.createWorktree({ | ||
| 231 | cwd: project.workspaceRoot, | ||
| 232 | refName: nextWorktree.baseBranch, | ||
| 233 | newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), | ||
| 234 | path: null, | ||
| 235 | }); | ||
| 236 | vcsRefManager.invalidate({ | ||
| 237 | environmentId: project.environmentId, | ||
| 238 | cwd: project.workspaceRoot, | ||
| 239 | query: null, | ||
| 240 | }); | ||
| 241 | return { | ||
| 242 | branch: result.worktree.refName, | ||
| 243 | worktreePath: result.worktree.path, | ||
| 244 | }; | ||
| 245 | } catch (error) { | ||
| 246 | setPendingConnectionError( | ||
| 247 | error instanceof Error ? error.message : "Failed to create worktree.", | ||
| 248 | ); | ||
| 249 | return null; | ||
| 250 | } | ||
| 251 | }, | ||
| 252 | [], | ||
| 253 | ); | ||
| 254 | |||
| 255 | return { | ||
| 256 | onCreateThread, | ||
| 257 | onCreateThreadWithOptions, | ||
| 258 | onListProjectBranches, | ||
| 259 | onCreateProjectWorktree, | ||
| 260 | onRefreshProjects: refreshRemoteData, | ||
| 261 | }; | ||
| 262 | } | ||
apps/mobile/src/lib/cn.ts created+6| ... | @@ -0,0 +1,6 @@ | ||
| 1 | import { type ClassValue, clsx } from "clsx"; | ||
| 2 | import { twMerge } from "tailwind-merge"; | ||
| 3 | |||
| 4 | export function cn(...inputs: ClassValue[]) { | ||
| 5 | return twMerge(clsx(inputs)); | ||
| 6 | } | ||
apps/mobile/src/lib/commandMetadata.test.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | import { makeQueuedMessageMetadata, makeTurnCommandMetadata } from "./commandMetadata"; | ||
| 4 | |||
| 5 | vi.mock("expo-crypto", () => ({ | ||
| 6 | randomUUID: () => crypto.randomUUID(), | ||
| 7 | })); | ||
| 8 | |||
| 9 | describe("mobile command metadata", () => { | ||
| 10 | it("creates ids and timestamps for thread starts", () => { | ||
| 11 | const metadata = makeTurnCommandMetadata(); | ||
| 12 | |||
| 13 | expect(metadata.commandId).toMatch( | ||
| 14 | /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, | ||
| 15 | ); | ||
| 16 | expect(metadata.messageId).toMatch( | ||
| 17 | /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, | ||
| 18 | ); | ||
| 19 | expect(metadata.threadId).toMatch( | ||
| 20 | /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, | ||
| 21 | ); | ||
| 22 | expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("creates ids and timestamps for queued messages", () => { | ||
| 26 | const metadata = makeQueuedMessageMetadata(); | ||
| 27 | |||
| 28 | expect(metadata.commandId).toMatch( | ||
| 29 | /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, | ||
| 30 | ); | ||
| 31 | expect(metadata.messageId).toMatch( | ||
| 32 | /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, | ||
| 33 | ); | ||
| 34 | expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); | ||
| 35 | }); | ||
| 36 | }); | ||
apps/mobile/src/lib/commandMetadata.ts created+25| ... | @@ -0,0 +1,25 @@ | ||
| 1 | import { uuidv4 } from "./uuid"; | ||
| 2 | |||
| 3 | export interface TurnCommandMetadata { | ||
| 4 | readonly commandId: string; | ||
| 5 | readonly messageId: string; | ||
| 6 | readonly threadId: string; | ||
| 7 | readonly createdAt: string; | ||
| 8 | } | ||
| 9 | |||
| 10 | export function makeTurnCommandMetadata(): TurnCommandMetadata { | ||
| 11 | return { | ||
| 12 | commandId: uuidv4(), | ||
| 13 | messageId: uuidv4(), | ||
| 14 | threadId: uuidv4(), | ||
| 15 | createdAt: new Date().toISOString(), | ||
| 16 | }; | ||
| 17 | } | ||
| 18 | |||
| 19 | export function makeQueuedMessageMetadata(): Omit<TurnCommandMetadata, "threadId"> { | ||
| 20 | return { | ||
| 21 | commandId: uuidv4(), | ||
| 22 | messageId: uuidv4(), | ||
| 23 | createdAt: new Date().toISOString(), | ||
| 24 | }; | ||
| 25 | } | ||
apps/mobile/src/lib/composerImages.ts created+249| ... | @@ -0,0 +1,249 @@ | ||
| 1 | import { | ||
| 2 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | ||
| 3 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | ||
| 4 | type UploadChatImageAttachment, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { uuidv4 } from "./uuid"; | ||
| 7 | |||
| 8 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { | ||
| 9 | readonly id: string; | ||
| 10 | readonly previewUri: string; | ||
| 11 | } | ||
| 12 | |||
| 13 | function estimateBase64ByteSize(base64: string): number { | ||
| 14 | const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; | ||
| 15 | return Math.floor((base64.length * 3) / 4) - padding; | ||
| 16 | } | ||
| 17 | |||
| 18 | async function loadImagePicker() { | ||
| 19 | try { | ||
| 20 | return await import("expo-image-picker"); | ||
| 21 | } catch (error) { | ||
| 22 | throw new Error("Image attachments are unavailable right now.", { cause: error }); | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | async function loadClipboard() { | ||
| 27 | try { | ||
| 28 | return await import("expo-clipboard"); | ||
| 29 | } catch (error) { | ||
| 30 | throw new Error("Clipboard paste is unavailable right now.", { cause: error }); | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{ | ||
| 35 | readonly images: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 36 | readonly error: string | null; | ||
| 37 | }> { | ||
| 38 | const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; | ||
| 39 | if (remainingSlots <= 0) { | ||
| 40 | return { | ||
| 41 | images: [], | ||
| 42 | error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 46 | let imagePicker: Awaited<ReturnType<typeof loadImagePicker>>; | ||
| 47 | try { | ||
| 48 | imagePicker = await loadImagePicker(); | ||
| 49 | } catch (error) { | ||
| 50 | return { | ||
| 51 | images: [], | ||
| 52 | error: | ||
| 53 | error instanceof Error ? error.message : "Image attachments are unavailable right now.", | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | |||
| 57 | const permission = await imagePicker.requestMediaLibraryPermissionsAsync(); | ||
| 58 | if (!permission.granted) { | ||
| 59 | return { | ||
| 60 | images: [], | ||
| 61 | error: "Allow photo library access to attach images.", | ||
| 62 | }; | ||
| 63 | } | ||
| 64 | |||
| 65 | const result = await imagePicker.launchImageLibraryAsync({ | ||
| 66 | mediaTypes: ["images"], | ||
| 67 | allowsMultipleSelection: true, | ||
| 68 | selectionLimit: remainingSlots, | ||
| 69 | base64: true, | ||
| 70 | quality: 1, | ||
| 71 | }); | ||
| 72 | |||
| 73 | if (result.canceled) { | ||
| 74 | return { | ||
| 75 | images: [], | ||
| 76 | error: null, | ||
| 77 | }; | ||
| 78 | } | ||
| 79 | |||
| 80 | const nextImages: DraftComposerImageAttachment[] = []; | ||
| 81 | let error: string | null = null; | ||
| 82 | |||
| 83 | for (const asset of result.assets) { | ||
| 84 | const mimeType = asset.mimeType?.toLowerCase(); | ||
| 85 | if (!mimeType?.startsWith("image/")) { | ||
| 86 | error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; | ||
| 87 | continue; | ||
| 88 | } | ||
| 89 | |||
| 90 | const base64 = asset.base64; | ||
| 91 | if (!base64) { | ||
| 92 | error = `Failed to read '${asset.fileName ?? "image"}'.`; | ||
| 93 | continue; | ||
| 94 | } | ||
| 95 | |||
| 96 | const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64); | ||
| 97 | if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | ||
| 98 | error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; | ||
| 99 | continue; | ||
| 100 | } | ||
| 101 | |||
| 102 | nextImages.push({ | ||
| 103 | id: uuidv4(), | ||
| 104 | type: "image", | ||
| 105 | name: asset.fileName ?? "image", | ||
| 106 | mimeType, | ||
| 107 | sizeBytes, | ||
| 108 | dataUrl: `data:${mimeType};base64,${base64}`, | ||
| 109 | previewUri: asset.uri, | ||
| 110 | }); | ||
| 111 | } | ||
| 112 | |||
| 113 | return { | ||
| 114 | images: nextImages, | ||
| 115 | error, | ||
| 116 | }; | ||
| 117 | } | ||
| 118 | |||
| 119 | export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{ | ||
| 120 | readonly images: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 121 | readonly text: string | null; | ||
| 122 | readonly error: string | null; | ||
| 123 | }> { | ||
| 124 | let clipboard: Awaited<ReturnType<typeof loadClipboard>>; | ||
| 125 | try { | ||
| 126 | clipboard = await loadClipboard(); | ||
| 127 | } catch (error) { | ||
| 128 | return { | ||
| 129 | images: [], | ||
| 130 | text: null, | ||
| 131 | error: error instanceof Error ? error.message : "Clipboard paste is unavailable right now.", | ||
| 132 | }; | ||
| 133 | } | ||
| 134 | |||
| 135 | const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; | ||
| 136 | |||
| 137 | if (await clipboard.hasImageAsync()) { | ||
| 138 | if (remainingSlots <= 0) { | ||
| 139 | return { | ||
| 140 | images: [], | ||
| 141 | text: null, | ||
| 142 | error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, | ||
| 143 | }; | ||
| 144 | } | ||
| 145 | const image = await clipboard.getImageAsync({ format: "png" }); | ||
| 146 | if (!image) { | ||
| 147 | return { | ||
| 148 | images: [], | ||
| 149 | text: null, | ||
| 150 | error: "Clipboard image is unavailable.", | ||
| 151 | }; | ||
| 152 | } | ||
| 153 | |||
| 154 | const base64 = image.data.split(",")[1] ?? ""; | ||
| 155 | const sizeBytes = estimateBase64ByteSize(base64); | ||
| 156 | if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | ||
| 157 | return { | ||
| 158 | images: [], | ||
| 159 | text: null, | ||
| 160 | error: "Clipboard image exceeds the 10 MB attachment limit.", | ||
| 161 | }; | ||
| 162 | } | ||
| 163 | |||
| 164 | return { | ||
| 165 | images: [ | ||
| 166 | { | ||
| 167 | id: uuidv4(), | ||
| 168 | type: "image", | ||
| 169 | name: "pasted-image.png", | ||
| 170 | mimeType: "image/png", | ||
| 171 | sizeBytes, | ||
| 172 | dataUrl: image.data, | ||
| 173 | previewUri: image.data, | ||
| 174 | }, | ||
| 175 | ], | ||
| 176 | text: null, | ||
| 177 | error: null, | ||
| 178 | }; | ||
| 179 | } | ||
| 180 | |||
| 181 | if (await clipboard.hasStringAsync()) { | ||
| 182 | const text = await clipboard.getStringAsync(); | ||
| 183 | return { | ||
| 184 | images: [], | ||
| 185 | text: text.length > 0 ? text : null, | ||
| 186 | error: text.length > 0 ? null : "Clipboard is empty.", | ||
| 187 | }; | ||
| 188 | } | ||
| 189 | |||
| 190 | return { | ||
| 191 | images: [], | ||
| 192 | text: null, | ||
| 193 | error: "Clipboard does not contain pasteable text or image content.", | ||
| 194 | }; | ||
| 195 | } | ||
| 196 | |||
| 197 | function mimeTypeFromUri(uri: string): string { | ||
| 198 | const ext = uri.split(".").pop()?.toLowerCase(); | ||
| 199 | switch (ext) { | ||
| 200 | case "png": | ||
| 201 | return "image/png"; | ||
| 202 | case "jpg": | ||
| 203 | case "jpeg": | ||
| 204 | return "image/jpeg"; | ||
| 205 | case "gif": | ||
| 206 | return "image/gif"; | ||
| 207 | case "webp": | ||
| 208 | return "image/webp"; | ||
| 209 | case "heic": | ||
| 210 | return "image/heic"; | ||
| 211 | default: | ||
| 212 | return "image/png"; | ||
| 213 | } | ||
| 214 | } | ||
| 215 | |||
| 216 | export async function convertPastedImagesToAttachments(input: { | ||
| 217 | readonly uris: ReadonlyArray<string>; | ||
| 218 | readonly existingCount: number; | ||
| 219 | }): Promise<ReadonlyArray<DraftComposerImageAttachment>> { | ||
| 220 | const { File } = await import("expo-file-system"); | ||
| 221 | const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; | ||
| 222 | const uris = input.uris.slice(0, Math.max(0, remainingSlots)); | ||
| 223 | const results: DraftComposerImageAttachment[] = []; | ||
| 224 | |||
| 225 | for (const uri of uris) { | ||
| 226 | try { | ||
| 227 | const file = new File(uri); | ||
| 228 | const base64 = await file.base64(); | ||
| 229 | const sizeBytes = estimateBase64ByteSize(base64); | ||
| 230 | if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | ||
| 231 | continue; | ||
| 232 | } | ||
| 233 | const mimeType = mimeTypeFromUri(uri); | ||
| 234 | results.push({ | ||
| 235 | id: uuidv4(), | ||
| 236 | type: "image", | ||
| 237 | name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`, | ||
| 238 | mimeType, | ||
| 239 | sizeBytes, | ||
| 240 | dataUrl: `data:${mimeType};base64,${base64}`, | ||
| 241 | previewUri: uri, | ||
| 242 | }); | ||
| 243 | } catch (error) { | ||
| 244 | console.warn("Failed to read pasted image", uri, error); | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 248 | return results; | ||
| 249 | } | ||
apps/mobile/src/lib/connection.test.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import { describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | import { redactPairingCredential } from "./connection"; | ||
| 4 | |||
| 5 | vi.mock("./runtime", () => ({ | ||
| 6 | mobileRemoteHttpRuntime: { | ||
| 7 | runPromise: vi.fn(), | ||
| 8 | }, | ||
| 9 | })); | ||
| 10 | |||
| 11 | describe("mobile remote connection records", () => { | ||
| 12 | it("removes one-time bootstrap credentials before persisting pairing URLs", () => { | ||
| 13 | expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe( | ||
| 14 | "https://desktop.example/", | ||
| 15 | ); | ||
| 16 | expect(redactPairingCredential("https://desktop.example/?token=bootstrap-token")).toBe( | ||
| 17 | "https://desktop.example/", | ||
| 18 | ); | ||
| 19 | }); | ||
| 20 | |||
| 21 | it("removes hosted pairing credentials while keeping the advertised host", () => { | ||
| 22 | expect( | ||
| 23 | redactPairingCredential( | ||
| 24 | "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&token=bootstrap-token&label=Desktop", | ||
| 25 | ), | ||
| 26 | ).toBe("https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&label=Desktop"); | ||
| 27 | }); | ||
| 28 | }); | ||
apps/mobile/src/lib/connection.ts created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | bootstrapRemoteBearerSession, | ||
| 4 | fetchRemoteEnvironmentDescriptor, | ||
| 5 | } from "@t3tools/client-runtime"; | ||
| 6 | import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; | ||
| 7 | import { mobileRemoteHttpRuntime } from "./runtime"; | ||
| 8 | |||
| 9 | export interface RemoteConnectionInput { | ||
| 10 | readonly pairingUrl: string; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface SavedRemoteConnection { | ||
| 14 | readonly environmentId: EnvironmentId; | ||
| 15 | readonly environmentLabel: string; | ||
| 16 | readonly pairingUrl: string; | ||
| 17 | readonly displayUrl: string; | ||
| 18 | readonly httpBaseUrl: string; | ||
| 19 | readonly wsBaseUrl: string; | ||
| 20 | readonly bearerToken: string; | ||
| 21 | } | ||
| 22 | |||
| 23 | export type RemoteClientConnectionState = | ||
| 24 | | "idle" | ||
| 25 | | "connecting" | ||
| 26 | | "ready" | ||
| 27 | | "reconnecting" | ||
| 28 | | "disconnected"; | ||
| 29 | |||
| 30 | export function redactPairingCredential(pairingUrl: string): string { | ||
| 31 | const trimmed = pairingUrl.trim(); | ||
| 32 | try { | ||
| 33 | return stripPairingTokenFromUrl(new URL(trimmed)).toString(); | ||
| 34 | } catch { | ||
| 35 | return trimmed; | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | export async function bootstrapRemoteConnection( | ||
| 40 | input: RemoteConnectionInput, | ||
| 41 | ): Promise<SavedRemoteConnection> { | ||
| 42 | const target = resolveRemotePairingTarget({ | ||
| 43 | pairingUrl: input.pairingUrl, | ||
| 44 | }); | ||
| 45 | |||
| 46 | const descriptor = await mobileRemoteHttpRuntime.runPromise( | ||
| 47 | fetchRemoteEnvironmentDescriptor({ | ||
| 48 | httpBaseUrl: target.httpBaseUrl, | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | |||
| 52 | const bootstrap = await mobileRemoteHttpRuntime.runPromise( | ||
| 53 | bootstrapRemoteBearerSession({ | ||
| 54 | httpBaseUrl: target.httpBaseUrl, | ||
| 55 | credential: target.credential, | ||
| 56 | }), | ||
| 57 | ); | ||
| 58 | |||
| 59 | return { | ||
| 60 | environmentId: descriptor.environmentId, | ||
| 61 | environmentLabel: descriptor.label, | ||
| 62 | pairingUrl: redactPairingCredential(input.pairingUrl), | ||
| 63 | displayUrl: target.httpBaseUrl, | ||
| 64 | httpBaseUrl: target.httpBaseUrl, | ||
| 65 | wsBaseUrl: target.wsBaseUrl, | ||
| 66 | bearerToken: bootstrap.sessionToken, | ||
| 67 | }; | ||
| 68 | } | ||
apps/mobile/src/lib/mobileLayout.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | function clamp(value: number, min: number, max: number): number { | ||
| 2 | return Math.min(Math.max(value, min), max); | ||
| 3 | } | ||
| 4 | |||
| 5 | export type MobileLayoutVariant = "compact" | "split"; | ||
| 6 | |||
| 7 | export interface MobileLayout { | ||
| 8 | readonly variant: MobileLayoutVariant; | ||
| 9 | readonly usesSplitView: boolean; | ||
| 10 | readonly listPaneWidth: number | null; | ||
| 11 | readonly shellPadding: number; | ||
| 12 | } | ||
| 13 | |||
| 14 | export function deriveMobileLayout(input: { | ||
| 15 | readonly width: number; | ||
| 16 | readonly height: number; | ||
| 17 | }): MobileLayout { | ||
| 18 | const { width, height } = input; | ||
| 19 | const shortestEdge = Math.min(width, height); | ||
| 20 | const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700); | ||
| 21 | |||
| 22 | if (!wideEnoughForSplit) { | ||
| 23 | return { | ||
| 24 | variant: "compact", | ||
| 25 | usesSplitView: false, | ||
| 26 | listPaneWidth: null, | ||
| 27 | shellPadding: 0, | ||
| 28 | }; | ||
| 29 | } | ||
| 30 | |||
| 31 | return { | ||
| 32 | variant: "split", | ||
| 33 | usesSplitView: true, | ||
| 34 | listPaneWidth: clamp(Math.round(width * 0.34), 320, 420), | ||
| 35 | shellPadding: width >= 1180 ? 20 : 14, | ||
| 36 | }; | ||
| 37 | } | ||
apps/mobile/src/lib/modelOptions.ts created+97| ... | @@ -0,0 +1,97 @@ | ||
| 1 | import type { ModelSelection, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export type ModelOption = { | ||
| 4 | readonly key: string; | ||
| 5 | readonly label: string; | ||
| 6 | readonly subtitle: string; | ||
| 7 | readonly providerKey: string; | ||
| 8 | readonly providerLabel: string; | ||
| 9 | readonly providerDriver: string; | ||
| 10 | readonly selection: ModelSelection; | ||
| 11 | }; | ||
| 12 | |||
| 13 | export type ProviderGroup = { | ||
| 14 | readonly providerKey: string; | ||
| 15 | readonly providerLabel: string; | ||
| 16 | readonly models: ReadonlyArray<ModelOption>; | ||
| 17 | }; | ||
| 18 | |||
| 19 | function providerDisplayLabel(provider: { | ||
| 20 | readonly displayName?: string | undefined; | ||
| 21 | readonly driver: string; | ||
| 22 | readonly instanceId: string; | ||
| 23 | }): string { | ||
| 24 | if (provider.displayName) return provider.displayName; | ||
| 25 | if (provider.driver === "codex") return "Codex"; | ||
| 26 | if (provider.driver === "claudeAgent") return "Claude"; | ||
| 27 | return provider.instanceId; | ||
| 28 | } | ||
| 29 | |||
| 30 | export function buildModelOptions( | ||
| 31 | config: T3ServerConfig | null | undefined, | ||
| 32 | fallbackModelSelection: ModelSelection | null, | ||
| 33 | ): ReadonlyArray<ModelOption> { | ||
| 34 | const options = new Map<string, ModelOption>(); | ||
| 35 | |||
| 36 | for (const provider of config?.providers ?? []) { | ||
| 37 | if (!provider.enabled || !provider.installed || provider.auth.status === "unauthenticated") { | ||
| 38 | continue; | ||
| 39 | } | ||
| 40 | |||
| 41 | const providerLabel = providerDisplayLabel(provider); | ||
| 42 | for (const model of provider.models) { | ||
| 43 | const key = `${provider.instanceId}:${model.slug}`; | ||
| 44 | options.set(key, { | ||
| 45 | key, | ||
| 46 | label: model.name, | ||
| 47 | subtitle: providerLabel, | ||
| 48 | providerKey: provider.instanceId, | ||
| 49 | providerLabel, | ||
| 50 | providerDriver: provider.driver, | ||
| 51 | selection: { | ||
| 52 | instanceId: provider.instanceId, | ||
| 53 | model: model.slug, | ||
| 54 | }, | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | if (fallbackModelSelection) { | ||
| 60 | const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; | ||
| 61 | if (!options.has(key)) { | ||
| 62 | const providerLabel = fallbackModelSelection.instanceId; | ||
| 63 | options.set(key, { | ||
| 64 | key, | ||
| 65 | label: fallbackModelSelection.model, | ||
| 66 | subtitle: providerLabel, | ||
| 67 | providerKey: fallbackModelSelection.instanceId, | ||
| 68 | providerLabel, | ||
| 69 | providerDriver: fallbackModelSelection.instanceId, | ||
| 70 | selection: fallbackModelSelection, | ||
| 71 | }); | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | return [...options.values()]; | ||
| 76 | } | ||
| 77 | |||
| 78 | export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyArray<ProviderGroup> { | ||
| 79 | const groups = new Map<string, { providerLabel: string; models: ModelOption[] }>(); | ||
| 80 | for (const option of options) { | ||
| 81 | const existing = groups.get(option.providerKey); | ||
| 82 | if (existing) { | ||
| 83 | existing.models.push(option); | ||
| 84 | } else { | ||
| 85 | groups.set(option.providerKey, { | ||
| 86 | providerLabel: option.providerLabel, | ||
| 87 | models: [option], | ||
| 88 | }); | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | return [...groups.entries()].map(([providerKey, group]) => ({ | ||
| 93 | providerKey, | ||
| 94 | providerLabel: group.providerLabel, | ||
| 95 | models: group.models, | ||
| 96 | })); | ||
| 97 | } | ||
apps/mobile/src/lib/repositoryGroups.test.ts created+197| ... | @@ -0,0 +1,197 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import { groupProjectsByRepository } from "./repositoryGroups"; | ||
| 6 | import { | ||
| 7 | EnvironmentScopedProjectShell, | ||
| 8 | EnvironmentScopedThreadShell, | ||
| 9 | } from "@t3tools/client-runtime"; | ||
| 10 | |||
| 11 | function makeProject( | ||
| 12 | input: Partial<EnvironmentScopedProjectShell> & | ||
| 13 | Pick<EnvironmentScopedProjectShell, "environmentId" | "id" | "title">, | ||
| 14 | ): EnvironmentScopedProjectShell { | ||
| 15 | return { | ||
| 16 | workspaceRoot: `/workspaces/${input.id}`, | ||
| 17 | repositoryIdentity: null, | ||
| 18 | defaultModelSelection: null, | ||
| 19 | scripts: [], | ||
| 20 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 21 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 22 | ...input, | ||
| 23 | }; | ||
| 24 | } | ||
| 25 | |||
| 26 | function makeThread( | ||
| 27 | input: Partial<EnvironmentScopedThreadShell> & | ||
| 28 | Pick< | ||
| 29 | EnvironmentScopedThreadShell, | ||
| 30 | "environmentId" | "id" | "projectId" | "title" | "modelSelection" | ||
| 31 | >, | ||
| 32 | ): EnvironmentScopedThreadShell { | ||
| 33 | return { | ||
| 34 | runtimeMode: "full-access", | ||
| 35 | interactionMode: "default", | ||
| 36 | branch: null, | ||
| 37 | worktreePath: null, | ||
| 38 | latestTurn: null, | ||
| 39 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 40 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 41 | archivedAt: null, | ||
| 42 | session: null, | ||
| 43 | latestUserMessageAt: null, | ||
| 44 | hasPendingApprovals: false, | ||
| 45 | hasPendingUserInput: false, | ||
| 46 | hasActionableProposedPlan: false, | ||
| 47 | ...input, | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | |||
| 51 | describe("groupProjectsByRepository", () => { | ||
| 52 | it("groups projects across environments by repository identity", () => { | ||
| 53 | const repoIdentity = { | ||
| 54 | canonicalKey: "github.com/t3tools/t3code", | ||
| 55 | locator: { | ||
| 56 | source: "git-remote" as const, | ||
| 57 | remoteName: "origin", | ||
| 58 | remoteUrl: "git@github.com:t3tools/t3code.git", | ||
| 59 | }, | ||
| 60 | provider: "github", | ||
| 61 | owner: "t3tools", | ||
| 62 | name: "t3code", | ||
| 63 | displayName: "T3 Code", | ||
| 64 | }; | ||
| 65 | |||
| 66 | const projects = [ | ||
| 67 | makeProject({ | ||
| 68 | environmentId: EnvironmentId.make("env-local"), | ||
| 69 | id: ProjectId.make("project-local"), | ||
| 70 | title: "T3 Code", | ||
| 71 | repositoryIdentity: repoIdentity, | ||
| 72 | }), | ||
| 73 | makeProject({ | ||
| 74 | environmentId: EnvironmentId.make("env-staging"), | ||
| 75 | id: ProjectId.make("project-staging"), | ||
| 76 | title: "T3 Code", | ||
| 77 | repositoryIdentity: repoIdentity, | ||
| 78 | }), | ||
| 79 | ]; | ||
| 80 | |||
| 81 | const threads = [ | ||
| 82 | makeThread({ | ||
| 83 | environmentId: EnvironmentId.make("env-staging"), | ||
| 84 | id: ThreadId.make("thread-2"), | ||
| 85 | projectId: ProjectId.make("project-staging"), | ||
| 86 | title: "Fix reconnect flow", | ||
| 87 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 88 | updatedAt: "2026-04-02T12:00:00.000Z", | ||
| 89 | }), | ||
| 90 | makeThread({ | ||
| 91 | environmentId: EnvironmentId.make("env-local"), | ||
| 92 | id: ThreadId.make("thread-1"), | ||
| 93 | projectId: ProjectId.make("project-local"), | ||
| 94 | title: "Polish mobile shell", | ||
| 95 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 96 | updatedAt: "2026-04-03T12:00:00.000Z", | ||
| 97 | }), | ||
| 98 | ]; | ||
| 99 | |||
| 100 | const groups = groupProjectsByRepository({ projects, threads }); | ||
| 101 | |||
| 102 | expect(groups).toHaveLength(1); | ||
| 103 | expect(groups[0]).toMatchObject({ | ||
| 104 | key: "github.com/t3tools/t3code", | ||
| 105 | title: "T3 Code", | ||
| 106 | subtitle: "t3tools/t3code", | ||
| 107 | projectCount: 2, | ||
| 108 | threadCount: 2, | ||
| 109 | }); | ||
| 110 | expect( | ||
| 111 | groups[0]?.projects.map((entry) => ({ | ||
| 112 | environmentId: entry.project.environmentId, | ||
| 113 | latestActivityAt: entry.latestActivityAt, | ||
| 114 | threads: entry.threads.map((thread) => thread.id), | ||
| 115 | })), | ||
| 116 | ).toEqual([ | ||
| 117 | { | ||
| 118 | environmentId: "env-local", | ||
| 119 | latestActivityAt: "2026-04-03T12:00:00.000Z", | ||
| 120 | threads: ["thread-1"], | ||
| 121 | }, | ||
| 122 | { | ||
| 123 | environmentId: "env-staging", | ||
| 124 | latestActivityAt: "2026-04-02T12:00:00.000Z", | ||
| 125 | threads: ["thread-2"], | ||
| 126 | }, | ||
| 127 | ]); | ||
| 128 | expect(groups[0]?.latestActivityAt).toBe("2026-04-03T12:00:00.000Z"); | ||
| 129 | }); | ||
| 130 | |||
| 131 | it("orders threads, projects, and repository groups by latest activity", () => { | ||
| 132 | const projects = [ | ||
| 133 | makeProject({ | ||
| 134 | environmentId: EnvironmentId.make("env-local"), | ||
| 135 | id: ProjectId.make("older-project"), | ||
| 136 | title: "Older", | ||
| 137 | }), | ||
| 138 | makeProject({ | ||
| 139 | environmentId: EnvironmentId.make("env-local"), | ||
| 140 | id: ProjectId.make("newer-project"), | ||
| 141 | title: "Newer", | ||
| 142 | }), | ||
| 143 | ]; | ||
| 144 | |||
| 145 | const threads = [ | ||
| 146 | makeThread({ | ||
| 147 | environmentId: EnvironmentId.make("env-local"), | ||
| 148 | id: ThreadId.make("older-thread"), | ||
| 149 | projectId: ProjectId.make("older-project"), | ||
| 150 | title: "Older thread", | ||
| 151 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 152 | updatedAt: "2026-04-02T12:00:00.000Z", | ||
| 153 | }), | ||
| 154 | makeThread({ | ||
| 155 | environmentId: EnvironmentId.make("env-local"), | ||
| 156 | id: ThreadId.make("newer-thread"), | ||
| 157 | projectId: ProjectId.make("older-project"), | ||
| 158 | title: "Newer thread", | ||
| 159 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 160 | updatedAt: "2026-04-04T12:00:00.000Z", | ||
| 161 | }), | ||
| 162 | makeThread({ | ||
| 163 | environmentId: EnvironmentId.make("env-local"), | ||
| 164 | id: ThreadId.make("newest-thread"), | ||
| 165 | projectId: ProjectId.make("newer-project"), | ||
| 166 | title: "Newest thread", | ||
| 167 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 168 | updatedAt: "2026-04-05T12:00:00.000Z", | ||
| 169 | }), | ||
| 170 | ]; | ||
| 171 | |||
| 172 | const groups = groupProjectsByRepository({ projects, threads }); | ||
| 173 | |||
| 174 | expect(groups.map((group) => group.title)).toEqual(["Newer", "Older"]); | ||
| 175 | expect(groups[1]?.projects[0]?.threads.map((thread) => thread.id)).toEqual([ | ||
| 176 | "newer-thread", | ||
| 177 | "older-thread", | ||
| 178 | ]); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("falls back to a scoped project key when repository identity is unavailable", () => { | ||
| 182 | const projects = [ | ||
| 183 | makeProject({ | ||
| 184 | environmentId: EnvironmentId.make("env-local"), | ||
| 185 | id: ProjectId.make("project-local"), | ||
| 186 | title: "Scratchpad", | ||
| 187 | }), | ||
| 188 | ]; | ||
| 189 | |||
| 190 | const groups = groupProjectsByRepository({ projects, threads: [] }); | ||
| 191 | |||
| 192 | expect(groups).toHaveLength(1); | ||
| 193 | expect(groups[0]?.key).toBe("env-local:project-local"); | ||
| 194 | expect(groups[0]?.title).toBe("Scratchpad"); | ||
| 195 | expect(groups[0]?.subtitle).toBeNull(); | ||
| 196 | }); | ||
| 197 | }); | ||
apps/mobile/src/lib/repositoryGroups.ts created+134| ... | @@ -0,0 +1,134 @@ | ||
| 1 | import * as Order from "effect/Order"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import type { RepositoryIdentity } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import { scopedProjectKey } from "./scopedEntities"; | ||
| 6 | import { | ||
| 7 | EnvironmentScopedProjectShell, | ||
| 8 | EnvironmentScopedThreadShell, | ||
| 9 | } from "@t3tools/client-runtime"; | ||
| 10 | |||
| 11 | const DateDescending = Order.flip(Order.Date); | ||
| 12 | |||
| 13 | export interface MobileRepositoryProjectGroup { | ||
| 14 | readonly key: string; | ||
| 15 | readonly project: EnvironmentScopedProjectShell; | ||
| 16 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | ||
| 17 | readonly latestActivityAt: string; | ||
| 18 | } | ||
| 19 | |||
| 20 | export interface MobileRepositoryGroup { | ||
| 21 | readonly key: string; | ||
| 22 | readonly title: string; | ||
| 23 | readonly subtitle: string | null; | ||
| 24 | readonly repositoryIdentity: RepositoryIdentity | null; | ||
| 25 | readonly projectCount: number; | ||
| 26 | readonly threadCount: number; | ||
| 27 | readonly latestActivityAt: string; | ||
| 28 | readonly projects: ReadonlyArray<MobileRepositoryProjectGroup>; | ||
| 29 | } | ||
| 30 | |||
| 31 | function compareIsoDateDescending(left: string, right: string): number { | ||
| 32 | return new Date(right).getTime() - new Date(left).getTime(); | ||
| 33 | } | ||
| 34 | |||
| 35 | function deriveRepositoryGroupKey(project: EnvironmentScopedProjectShell): string { | ||
| 36 | return ( | ||
| 37 | project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) | ||
| 38 | ); | ||
| 39 | } | ||
| 40 | |||
| 41 | function deriveRepositoryTitle(project: EnvironmentScopedProjectShell): string { | ||
| 42 | const identity = project.repositoryIdentity; | ||
| 43 | return identity?.displayName ?? identity?.name ?? project.title; | ||
| 44 | } | ||
| 45 | |||
| 46 | function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefined): string | null { | ||
| 47 | if (!identity) { | ||
| 48 | return null; | ||
| 49 | } | ||
| 50 | if (identity.owner && identity.name) { | ||
| 51 | return `${identity.owner}/${identity.name}`; | ||
| 52 | } | ||
| 53 | return identity.canonicalKey; | ||
| 54 | } | ||
| 55 | |||
| 56 | function deriveProjectLatestActivity( | ||
| 57 | project: EnvironmentScopedProjectShell, | ||
| 58 | threads: ReadonlyArray<EnvironmentScopedThreadShell>, | ||
| 59 | ): string { | ||
| 60 | const latestThread = threads[0]; | ||
| 61 | return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt; | ||
| 62 | } | ||
| 63 | |||
| 64 | export function groupProjectsByRepository(input: { | ||
| 65 | readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>; | ||
| 66 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | ||
| 67 | }): ReadonlyArray<MobileRepositoryGroup> { | ||
| 68 | const threadsByProjectKey = new Map<string, EnvironmentScopedThreadShell[]>(); | ||
| 69 | |||
| 70 | for (const thread of input.threads) { | ||
| 71 | const key = scopedProjectKey(thread.environmentId, thread.projectId); | ||
| 72 | const existing = threadsByProjectKey.get(key); | ||
| 73 | if (existing) { | ||
| 74 | existing.push(thread); | ||
| 75 | } else { | ||
| 76 | threadsByProjectKey.set(key, [thread]); | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | const grouped = new Map<string, MobileRepositoryGroup>(); | ||
| 81 | |||
| 82 | for (const project of input.projects) { | ||
| 83 | const key = deriveRepositoryGroupKey(project); | ||
| 84 | const projectKey = scopedProjectKey(project.environmentId, project.id); | ||
| 85 | const threads = Arr.sortWith( | ||
| 86 | threadsByProjectKey.get(projectKey) ?? [], | ||
| 87 | (s) => new Date(s.updatedAt ?? s.createdAt), | ||
| 88 | DateDescending, | ||
| 89 | ); | ||
| 90 | |||
| 91 | const latestActivityAt = deriveProjectLatestActivity(project, threads); | ||
| 92 | const projectGroup: MobileRepositoryProjectGroup = { | ||
| 93 | key: projectKey, | ||
| 94 | project, | ||
| 95 | threads, | ||
| 96 | latestActivityAt, | ||
| 97 | }; | ||
| 98 | |||
| 99 | const existing = grouped.get(key); | ||
| 100 | if (!existing) { | ||
| 101 | grouped.set(key, { | ||
| 102 | key, | ||
| 103 | title: deriveRepositoryTitle(project), | ||
| 104 | subtitle: deriveRepositorySubtitle(project.repositoryIdentity), | ||
| 105 | repositoryIdentity: project.repositoryIdentity ?? null, | ||
| 106 | projectCount: 1, | ||
| 107 | threadCount: threads.length, | ||
| 108 | latestActivityAt, | ||
| 109 | projects: [projectGroup], | ||
| 110 | }); | ||
| 111 | continue; | ||
| 112 | } | ||
| 113 | |||
| 114 | grouped.set(key, { | ||
| 115 | ...existing, | ||
| 116 | title: existing.repositoryIdentity ? existing.title : deriveRepositoryTitle(project), | ||
| 117 | subtitle: existing.subtitle ?? deriveRepositorySubtitle(project.repositoryIdentity), | ||
| 118 | repositoryIdentity: existing.repositoryIdentity ?? project.repositoryIdentity ?? null, | ||
| 119 | projectCount: existing.projectCount + 1, | ||
| 120 | threadCount: existing.threadCount + threads.length, | ||
| 121 | latestActivityAt: | ||
| 122 | compareIsoDateDescending(existing.latestActivityAt, latestActivityAt) > 0 | ||
| 123 | ? latestActivityAt | ||
| 124 | : existing.latestActivityAt, | ||
| 125 | projects: Arr.sortWith( | ||
| 126 | [...existing.projects, projectGroup], | ||
| 127 | (s) => new Date(s.latestActivityAt), | ||
| 128 | DateDescending, | ||
| 129 | ), | ||
| 130 | }); | ||
| 131 | } | ||
| 132 | |||
| 133 | return Arr.sortWith(grouped.values(), (s) => new Date(s.latestActivityAt), DateDescending); | ||
| 134 | } | ||
apps/mobile/src/lib/routes.ts created+81| ... | @@ -0,0 +1,81 @@ | ||
| 1 | import type { Href, useRouter } from "expo-router"; | ||
| 2 | import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; | ||
| 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import type { SelectedThreadRef } from "../state/remote-runtime-types"; | ||
| 6 | |||
| 7 | type Router = ReturnType<typeof useRouter>; | ||
| 8 | |||
| 9 | type ThreadRouteInput = | ||
| 10 | | Pick<SelectedThreadRef, "environmentId" | "threadId"> | ||
| 11 | | Pick<EnvironmentScopedThreadShell, "environmentId" | "id">; | ||
| 12 | type PlainThreadRouteInput = | ||
| 13 | | { | ||
| 14 | environmentId: EnvironmentId; | ||
| 15 | threadId: ThreadId; | ||
| 16 | } | ||
| 17 | | { | ||
| 18 | environmentId: EnvironmentId; | ||
| 19 | id: ThreadId; | ||
| 20 | }; | ||
| 21 | |||
| 22 | export function buildThreadRoutePath(input: ThreadRouteInput | PlainThreadRouteInput): string { | ||
| 23 | const environmentId = input.environmentId; | ||
| 24 | const threadId = "threadId" in input ? input.threadId : input.id; | ||
| 25 | |||
| 26 | return `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`; | ||
| 27 | } | ||
| 28 | |||
| 29 | export function buildThreadReviewRoutePath( | ||
| 30 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 31 | ): string { | ||
| 32 | return `${buildThreadRoutePath(input)}/review`; | ||
| 33 | } | ||
| 34 | |||
| 35 | export function buildThreadTerminalRoutePath( | ||
| 36 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 37 | terminalId?: string | null, | ||
| 38 | ): string { | ||
| 39 | const basePath = `${buildThreadRoutePath(input)}/terminal`; | ||
| 40 | if (!terminalId) { | ||
| 41 | return basePath; | ||
| 42 | } | ||
| 43 | |||
| 44 | return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`; | ||
| 45 | } | ||
| 46 | |||
| 47 | /** | ||
| 48 | * Prefer this over {@link buildThreadTerminalRoutePath} with `router.push(string)` — Expo Router | ||
| 49 | * often does not merge query strings into `useLocalSearchParams`, which breaks terminal bootstrap | ||
| 50 | * (`requestedTerminalId` stays null while the UI assumes `default`). | ||
| 51 | */ | ||
| 52 | export function buildThreadTerminalNavigation( | ||
| 53 | input: ThreadRouteInput | PlainThreadRouteInput, | ||
| 54 | terminalId?: string | null, | ||
| 55 | ): Href { | ||
| 56 | const environmentId = String(input.environmentId); | ||
| 57 | const threadId = String("threadId" in input ? input.threadId : input.id); | ||
| 58 | |||
| 59 | const params: { environmentId: string; threadId: string; terminalId?: string } = { | ||
| 60 | environmentId, | ||
| 61 | threadId, | ||
| 62 | }; | ||
| 63 | |||
| 64 | if (terminalId != null && terminalId !== "") { | ||
| 65 | params.terminalId = terminalId; | ||
| 66 | } | ||
| 67 | |||
| 68 | return { | ||
| 69 | pathname: "/threads/[environmentId]/[threadId]/terminal", | ||
| 70 | params, | ||
| 71 | }; | ||
| 72 | } | ||
| 73 | |||
| 74 | export function dismissRoute(router: Router) { | ||
| 75 | if (router.canGoBack()) { | ||
| 76 | router.back(); | ||
| 77 | return; | ||
| 78 | } | ||
| 79 | |||
| 80 | router.replace("/"); | ||
| 81 | } | ||
apps/mobile/src/lib/runtime.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import * as ManagedRuntime from "effect/ManagedRuntime"; | ||
| 2 | |||
| 3 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | ||
| 4 | |||
| 5 | export const mobileRemoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(fetch)); | ||
apps/mobile/src/lib/scopedEntities.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { ApprovalRequestId, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string { | ||
| 4 | return `${environmentId}:${projectId}`; | ||
| 5 | } | ||
| 6 | |||
| 7 | export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId): string { | ||
| 8 | return `${environmentId}:${threadId}`; | ||
| 9 | } | ||
| 10 | |||
| 11 | export function scopedRequestKey( | ||
| 12 | environmentId: EnvironmentId, | ||
| 13 | requestId: ApprovalRequestId, | ||
| 14 | ): string { | ||
| 15 | return `${environmentId}:${requestId}`; | ||
| 16 | } | ||
apps/mobile/src/lib/storage.ts created+184| ... | @@ -0,0 +1,184 @@ | ||
| 1 | import * as Arr from "effect/Array"; | ||
| 2 | import { pipe } from "effect/Function"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import * as SecureStore from "expo-secure-store"; | ||
| 6 | import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | import type { SavedRemoteConnection } from "./connection"; | ||
| 9 | |||
| 10 | const CONNECTIONS_KEY = "t3code.connections"; | ||
| 11 | const PREFERENCES_KEY = "t3code.preferences"; | ||
| 12 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | ||
| 13 | const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; | ||
| 14 | |||
| 15 | export interface CachedShellSnapshot { | ||
| 16 | readonly schemaVersion: typeof SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION; | ||
| 17 | readonly environmentId: EnvironmentId; | ||
| 18 | readonly snapshotReceivedAt: string; | ||
| 19 | readonly snapshot: OrchestrationShellSnapshot; | ||
| 20 | } | ||
| 21 | |||
| 22 | export interface MobilePreferences { | ||
| 23 | readonly terminalFontSize?: number; | ||
| 24 | } | ||
| 25 | |||
| 26 | const CachedShellSnapshotSchema = Schema.Struct({ | ||
| 27 | schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), | ||
| 28 | environmentId: EnvironmentId, | ||
| 29 | snapshotReceivedAt: Schema.String, | ||
| 30 | snapshot: OrchestrationShellSnapshot, | ||
| 31 | }); | ||
| 32 | const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema); | ||
| 33 | |||
| 34 | async function readStorageItem(key: string): Promise<string | null> { | ||
| 35 | return await SecureStore.getItemAsync(key); | ||
| 36 | } | ||
| 37 | |||
| 38 | async function writeStorageItem(key: string, value: string): Promise<void> { | ||
| 39 | await SecureStore.setItemAsync(key, value); | ||
| 40 | } | ||
| 41 | |||
| 42 | async function readJsonStorageItem<T>(key: string): Promise<T | null> { | ||
| 43 | const raw = (await readStorageItem(key)) ?? ""; | ||
| 44 | if (!raw.trim()) { | ||
| 45 | return null; | ||
| 46 | } | ||
| 47 | |||
| 48 | try { | ||
| 49 | return JSON.parse(raw) as T; | ||
| 50 | } catch { | ||
| 51 | return null; | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | function cachedShellSnapshotFileName(environmentId: EnvironmentId): string { | ||
| 56 | return `${encodeURIComponent(environmentId)}.json`; | ||
| 57 | } | ||
| 58 | |||
| 59 | async function getShellSnapshotCacheDirectory() { | ||
| 60 | const { Directory, Paths } = await import("expo-file-system"); | ||
| 61 | const directory = new Directory(Paths.document, SHELL_SNAPSHOT_CACHE_DIRECTORY); | ||
| 62 | directory.create({ idempotent: true, intermediates: true }); | ||
| 63 | return directory; | ||
| 64 | } | ||
| 65 | |||
| 66 | export async function loadCachedShellSnapshot( | ||
| 67 | environmentId: EnvironmentId, | ||
| 68 | ): Promise<CachedShellSnapshot | null> { | ||
| 69 | try { | ||
| 70 | const { File } = await import("expo-file-system"); | ||
| 71 | const directory = await getShellSnapshotCacheDirectory(); | ||
| 72 | const file = new File(directory, cachedShellSnapshotFileName(environmentId)); | ||
| 73 | if (!file.exists) { | ||
| 74 | return null; | ||
| 75 | } | ||
| 76 | |||
| 77 | const parsed = JSON.parse(await file.text()) as unknown; | ||
| 78 | const decoded = decodeCachedShellSnapshot(parsed); | ||
| 79 | if (Option.isNone(decoded) || decoded.value.environmentId !== environmentId) { | ||
| 80 | return null; | ||
| 81 | } | ||
| 82 | |||
| 83 | return decoded.value; | ||
| 84 | } catch { | ||
| 85 | return null; | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | export async function saveCachedShellSnapshot( | ||
| 90 | environmentId: EnvironmentId, | ||
| 91 | snapshot: OrchestrationShellSnapshot, | ||
| 92 | ): Promise<void> { | ||
| 93 | try { | ||
| 94 | const { File } = await import("expo-file-system"); | ||
| 95 | const directory = await getShellSnapshotCacheDirectory(); | ||
| 96 | const file = new File(directory, cachedShellSnapshotFileName(environmentId)); | ||
| 97 | const document: CachedShellSnapshot = { | ||
| 98 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | ||
| 99 | environmentId, | ||
| 100 | snapshotReceivedAt: new Date().toISOString(), | ||
| 101 | snapshot, | ||
| 102 | }; | ||
| 103 | |||
| 104 | if (!file.exists) { | ||
| 105 | file.create({ intermediates: true, overwrite: true }); | ||
| 106 | } | ||
| 107 | file.write(JSON.stringify(document)); | ||
| 108 | } catch { | ||
| 109 | // Cache persistence is best-effort and should never block live data. | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | export async function clearCachedShellSnapshot(environmentId: EnvironmentId): Promise<void> { | ||
| 114 | try { | ||
| 115 | const { File } = await import("expo-file-system"); | ||
| 116 | const directory = await getShellSnapshotCacheDirectory(); | ||
| 117 | const file = new File(directory, cachedShellSnapshotFileName(environmentId)); | ||
| 118 | if (file.exists) { | ||
| 119 | file.delete(); | ||
| 120 | } | ||
| 121 | } catch { | ||
| 122 | // Ignore cache cleanup failures. | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> { | ||
| 127 | const parsed = await readJsonStorageItem<{ | ||
| 128 | readonly connections?: ReadonlyArray<SavedRemoteConnection>; | ||
| 129 | }>(CONNECTIONS_KEY); | ||
| 130 | if (!parsed) { | ||
| 131 | return []; | ||
| 132 | } | ||
| 133 | |||
| 134 | return pipe( | ||
| 135 | parsed.connections ?? [], | ||
| 136 | Arr.filter((c) => !!c.environmentId && !!c.bearerToken?.trim()), | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | |||
| 140 | export async function saveConnection(connection: SavedRemoteConnection): Promise<void> { | ||
| 141 | const current = await loadSavedConnections(); | ||
| 142 | const next = current.some((entry) => entry.environmentId === connection.environmentId) | ||
| 143 | ? pipe( | ||
| 144 | current, | ||
| 145 | Arr.map((entry) => (entry.environmentId === connection.environmentId ? connection : entry)), | ||
| 146 | ) | ||
| 147 | : pipe(current, Arr.append(connection)); | ||
| 148 | |||
| 149 | await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); | ||
| 150 | } | ||
| 151 | |||
| 152 | export async function clearSavedConnection(environmentId: EnvironmentId): Promise<void> { | ||
| 153 | const current = await loadSavedConnections(); | ||
| 154 | const next = pipe( | ||
| 155 | current, | ||
| 156 | Arr.filter((entry) => entry.environmentId !== environmentId), | ||
| 157 | ); | ||
| 158 | await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); | ||
| 159 | } | ||
| 160 | |||
| 161 | export async function loadPreferences(): Promise<MobilePreferences> { | ||
| 162 | const parsed = await readJsonStorageItem<MobilePreferences>(PREFERENCES_KEY); | ||
| 163 | if (!parsed || typeof parsed !== "object") { | ||
| 164 | return {}; | ||
| 165 | } | ||
| 166 | |||
| 167 | if (typeof parsed.terminalFontSize === "number") { | ||
| 168 | return { terminalFontSize: parsed.terminalFontSize }; | ||
| 169 | } | ||
| 170 | |||
| 171 | return {}; | ||
| 172 | } | ||
| 173 | |||
| 174 | export async function savePreferencesPatch( | ||
| 175 | patch: Partial<MobilePreferences>, | ||
| 176 | ): Promise<MobilePreferences> { | ||
| 177 | const current = await loadPreferences(); | ||
| 178 | const next: MobilePreferences = { | ||
| 179 | ...current, | ||
| 180 | ...patch, | ||
| 181 | }; | ||
| 182 | await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); | ||
| 183 | return next; | ||
| 184 | } | ||
apps/mobile/src/lib/threadActivity.test.ts created+172| ... | @@ -0,0 +1,172 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | EventId, | ||
| 5 | ProjectId, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | TurnId, | ||
| 9 | type OrchestrationThread, | ||
| 10 | type OrchestrationThreadActivity, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | |||
| 13 | import { buildThreadFeed } from "./threadActivity"; | ||
| 14 | |||
| 15 | function makeActivity( | ||
| 16 | input: Partial<OrchestrationThreadActivity> & | ||
| 17 | Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">, | ||
| 18 | ): OrchestrationThreadActivity { | ||
| 19 | return { | ||
| 20 | tone: "info", | ||
| 21 | payload: {}, | ||
| 22 | turnId: null, | ||
| 23 | ...input, | ||
| 24 | }; | ||
| 25 | } | ||
| 26 | |||
| 27 | function makeThread( | ||
| 28 | input: Partial<OrchestrationThread> & Pick<OrchestrationThread, "id" | "projectId" | "title">, | ||
| 29 | ): OrchestrationThread { | ||
| 30 | return { | ||
| 31 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 32 | runtimeMode: "full-access", | ||
| 33 | interactionMode: "default", | ||
| 34 | branch: null, | ||
| 35 | worktreePath: null, | ||
| 36 | latestTurn: null, | ||
| 37 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 38 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 39 | archivedAt: null, | ||
| 40 | deletedAt: null, | ||
| 41 | messages: [], | ||
| 42 | proposedPlans: [], | ||
| 43 | activities: [], | ||
| 44 | checkpoints: [], | ||
| 45 | session: null, | ||
| 46 | ...input, | ||
| 47 | }; | ||
| 48 | } | ||
| 49 | |||
| 50 | describe("buildThreadFeed", () => { | ||
| 51 | it("includes runtime warnings from the latest turn", () => { | ||
| 52 | const thread = makeThread({ | ||
| 53 | id: ThreadId.make("thread-1"), | ||
| 54 | projectId: ProjectId.make("project-1"), | ||
| 55 | title: "Runtime warning thread", | ||
| 56 | latestTurn: { | ||
| 57 | turnId: TurnId.make("turn-latest"), | ||
| 58 | state: "running", | ||
| 59 | requestedAt: "2026-04-01T00:00:00.000Z", | ||
| 60 | startedAt: "2026-04-01T00:00:01.000Z", | ||
| 61 | completedAt: null, | ||
| 62 | assistantMessageId: null, | ||
| 63 | }, | ||
| 64 | activities: [ | ||
| 65 | makeActivity({ | ||
| 66 | id: EventId.make("activity-old"), | ||
| 67 | kind: "runtime.warning", | ||
| 68 | summary: "Runtime warning", | ||
| 69 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 70 | turnId: TurnId.make("turn-old"), | ||
| 71 | payload: { | ||
| 72 | message: "Old warning", | ||
| 73 | }, | ||
| 74 | }), | ||
| 75 | makeActivity({ | ||
| 76 | id: EventId.make("activity-latest"), | ||
| 77 | kind: "runtime.warning", | ||
| 78 | summary: "Runtime warning", | ||
| 79 | createdAt: "2026-04-01T00:00:03.000Z", | ||
| 80 | turnId: TurnId.make("turn-latest"), | ||
| 81 | payload: { | ||
| 82 | message: "Latest warning", | ||
| 83 | }, | ||
| 84 | }), | ||
| 85 | ], | ||
| 86 | }); | ||
| 87 | |||
| 88 | const feed = buildThreadFeed(thread, [], null); | ||
| 89 | const group = feed[0]; | ||
| 90 | |||
| 91 | expect(group).toMatchObject({ | ||
| 92 | type: "activity-group", | ||
| 93 | }); | ||
| 94 | if (!group || group.type !== "activity-group") { | ||
| 95 | return; | ||
| 96 | } | ||
| 97 | |||
| 98 | expect(group.activities).toEqual([ | ||
| 99 | { | ||
| 100 | id: "activity-latest", | ||
| 101 | createdAt: "2026-04-01T00:00:03.000Z", | ||
| 102 | summary: "Runtime warning", | ||
| 103 | detail: null, | ||
| 104 | status: null, | ||
| 105 | }, | ||
| 106 | ]); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("collapses matching tool lifecycle rows like desktop", () => { | ||
| 110 | const thread = makeThread({ | ||
| 111 | id: ThreadId.make("thread-2"), | ||
| 112 | projectId: ProjectId.make("project-1"), | ||
| 113 | title: "Collapsed tools", | ||
| 114 | latestTurn: { | ||
| 115 | turnId: TurnId.make("turn-1"), | ||
| 116 | state: "completed", | ||
| 117 | requestedAt: "2026-04-01T00:00:00.000Z", | ||
| 118 | startedAt: "2026-04-01T00:00:01.000Z", | ||
| 119 | completedAt: "2026-04-01T00:00:03.000Z", | ||
| 120 | assistantMessageId: null, | ||
| 121 | }, | ||
| 122 | activities: [ | ||
| 123 | makeActivity({ | ||
| 124 | id: EventId.make("tool-updated"), | ||
| 125 | kind: "tool.updated", | ||
| 126 | tone: "tool", | ||
| 127 | summary: "Run tests", | ||
| 128 | createdAt: "2026-04-01T00:00:01.000Z", | ||
| 129 | turnId: TurnId.make("turn-1"), | ||
| 130 | payload: { | ||
| 131 | title: "Run tests", | ||
| 132 | itemType: "command_execution", | ||
| 133 | detail: "/bin/zsh -lc 'bun run test'", | ||
| 134 | }, | ||
| 135 | }), | ||
| 136 | makeActivity({ | ||
| 137 | id: EventId.make("tool-completed"), | ||
| 138 | kind: "tool.completed", | ||
| 139 | tone: "tool", | ||
| 140 | summary: "Run tests completed", | ||
| 141 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 142 | turnId: TurnId.make("turn-1"), | ||
| 143 | payload: { | ||
| 144 | title: "Run tests", | ||
| 145 | itemType: "command_execution", | ||
| 146 | detail: "/bin/zsh -lc 'bun run test'", | ||
| 147 | }, | ||
| 148 | }), | ||
| 149 | ], | ||
| 150 | }); | ||
| 151 | |||
| 152 | const feed = buildThreadFeed(thread, [], null); | ||
| 153 | const group = feed[0]; | ||
| 154 | |||
| 155 | expect(group).toMatchObject({ | ||
| 156 | type: "activity-group", | ||
| 157 | }); | ||
| 158 | if (!group || group.type !== "activity-group") { | ||
| 159 | return; | ||
| 160 | } | ||
| 161 | |||
| 162 | expect(group.activities).toEqual([ | ||
| 163 | { | ||
| 164 | id: "tool-completed", | ||
| 165 | createdAt: "2026-04-01T00:00:02.000Z", | ||
| 166 | summary: "Run tests", | ||
| 167 | detail: "bun run test", | ||
| 168 | status: null, | ||
| 169 | }, | ||
| 170 | ]); | ||
| 171 | }); | ||
| 172 | }); | ||
apps/mobile/src/lib/threadActivity.ts created+942| ... | @@ -0,0 +1,942 @@ | ||
| 1 | import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; | ||
| 2 | import type { | ||
| 3 | CommandId, | ||
| 4 | EnvironmentId, | ||
| 5 | MessageId, | ||
| 6 | OrchestrationThread, | ||
| 7 | OrchestrationThreadActivity, | ||
| 8 | TurnId, | ||
| 9 | ToolLifecycleItemType, | ||
| 10 | ThreadId, | ||
| 11 | UserInputQuestion, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | |||
| 14 | import type { DraftComposerImageAttachment } from "./composerImages"; | ||
| 15 | import * as Arr from "effect/Array"; | ||
| 16 | import * as Order from "effect/Order"; | ||
| 17 | |||
| 18 | export interface PendingApproval { | ||
| 19 | readonly requestId: ApprovalRequestId; | ||
| 20 | readonly requestKind: "command" | "file-read" | "file-change"; | ||
| 21 | readonly createdAt: string; | ||
| 22 | readonly detail?: string; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface PendingUserInput { | ||
| 26 | readonly requestId: ApprovalRequestId; | ||
| 27 | readonly createdAt: string; | ||
| 28 | readonly questions: ReadonlyArray<UserInputQuestion>; | ||
| 29 | } | ||
| 30 | |||
| 31 | export interface PendingUserInputDraftAnswer { | ||
| 32 | readonly selectedOptionLabel?: string; | ||
| 33 | readonly customAnswer?: string; | ||
| 34 | } | ||
| 35 | |||
| 36 | export interface QueuedThreadMessage { | ||
| 37 | readonly environmentId: EnvironmentId; | ||
| 38 | readonly threadId: ThreadId; | ||
| 39 | readonly messageId: MessageId; | ||
| 40 | readonly commandId: CommandId; | ||
| 41 | readonly text: string; | ||
| 42 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 43 | readonly createdAt: string; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface ThreadFeedActivity { | ||
| 47 | readonly id: string; | ||
| 48 | readonly createdAt: string; | ||
| 49 | readonly summary: string; | ||
| 50 | readonly detail: string | null; | ||
| 51 | readonly status: string | null; | ||
| 52 | } | ||
| 53 | |||
| 54 | interface WorkLogEntry { | ||
| 55 | id: string; | ||
| 56 | createdAt: string; | ||
| 57 | label: string; | ||
| 58 | detail?: string; | ||
| 59 | command?: string; | ||
| 60 | rawCommand?: string; | ||
| 61 | changedFiles?: ReadonlyArray<string>; | ||
| 62 | tone: "thinking" | "tool" | "info" | "error"; | ||
| 63 | toolTitle?: string; | ||
| 64 | itemType?: ToolLifecycleItemType; | ||
| 65 | requestKind?: PendingApproval["requestKind"]; | ||
| 66 | } | ||
| 67 | |||
| 68 | interface DerivedWorkLogEntry extends WorkLogEntry { | ||
| 69 | activityKind: OrchestrationThreadActivity["kind"]; | ||
| 70 | collapseKey?: string; | ||
| 71 | } | ||
| 72 | |||
| 73 | type RawThreadFeedEntry = | ||
| 74 | | { | ||
| 75 | readonly type: "message"; | ||
| 76 | readonly id: string; | ||
| 77 | readonly createdAt: string; | ||
| 78 | readonly message: OrchestrationThread["messages"][number]; | ||
| 79 | } | ||
| 80 | | { | ||
| 81 | readonly type: "queued-message"; | ||
| 82 | readonly id: string; | ||
| 83 | readonly createdAt: string; | ||
| 84 | readonly queuedMessage: QueuedThreadMessage; | ||
| 85 | readonly sending: boolean; | ||
| 86 | } | ||
| 87 | | { | ||
| 88 | readonly type: "activity"; | ||
| 89 | readonly id: string; | ||
| 90 | readonly createdAt: string; | ||
| 91 | readonly activity: ThreadFeedActivity; | ||
| 92 | }; | ||
| 93 | |||
| 94 | export type ThreadFeedEntry = | ||
| 95 | | Extract<RawThreadFeedEntry, { type: "message" | "queued-message" }> | ||
| 96 | | { | ||
| 97 | readonly type: "activity-group"; | ||
| 98 | readonly id: string; | ||
| 99 | readonly createdAt: string; | ||
| 100 | readonly activities: ReadonlyArray<ThreadFeedActivity>; | ||
| 101 | }; | ||
| 102 | |||
| 103 | function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { | ||
| 104 | switch (requestType) { | ||
| 105 | case "command_execution_approval": | ||
| 106 | case "exec_command_approval": | ||
| 107 | return "command"; | ||
| 108 | case "file_read_approval": | ||
| 109 | return "file-read"; | ||
| 110 | case "file_change_approval": | ||
| 111 | case "apply_patch_approval": | ||
| 112 | return "file-change"; | ||
| 113 | default: | ||
| 114 | return null; | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { | ||
| 119 | const normalized = detail?.toLowerCase(); | ||
| 120 | if (!normalized) { | ||
| 121 | return false; | ||
| 122 | } | ||
| 123 | return ( | ||
| 124 | normalized.includes("stale pending approval request") || | ||
| 125 | normalized.includes("stale pending user-input request") || | ||
| 126 | normalized.includes("unknown pending approval request") || | ||
| 127 | normalized.includes("unknown pending permission request") || | ||
| 128 | normalized.includes("unknown pending user-input request") | ||
| 129 | ); | ||
| 130 | } | ||
| 131 | |||
| 132 | function parseApprovalRequestId(value: unknown): ApprovalRequestId | null { | ||
| 133 | return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null; | ||
| 134 | } | ||
| 135 | |||
| 136 | function parseUserInputQuestions( | ||
| 137 | payload: Record<string, unknown> | null, | ||
| 138 | ): ReadonlyArray<UserInputQuestion> | null { | ||
| 139 | const questions = payload?.questions; | ||
| 140 | if (!Array.isArray(questions)) { | ||
| 141 | return null; | ||
| 142 | } | ||
| 143 | |||
| 144 | const parsed = questions | ||
| 145 | .map<UserInputQuestion | null>((entry) => { | ||
| 146 | if (!entry || typeof entry !== "object") return null; | ||
| 147 | const question = entry as Record<string, unknown>; | ||
| 148 | if ( | ||
| 149 | typeof question.id !== "string" || | ||
| 150 | typeof question.header !== "string" || | ||
| 151 | typeof question.question !== "string" || | ||
| 152 | !Array.isArray(question.options) | ||
| 153 | ) { | ||
| 154 | return null; | ||
| 155 | } | ||
| 156 | const options = question.options | ||
| 157 | .map<UserInputQuestion["options"][number] | null>((option) => { | ||
| 158 | if (!option || typeof option !== "object") return null; | ||
| 159 | const record = option as Record<string, unknown>; | ||
| 160 | if (typeof record.label !== "string" || typeof record.description !== "string") { | ||
| 161 | return null; | ||
| 162 | } | ||
| 163 | return { | ||
| 164 | label: record.label, | ||
| 165 | description: record.description, | ||
| 166 | }; | ||
| 167 | }) | ||
| 168 | .filter((option): option is UserInputQuestion["options"][number] => option !== null); | ||
| 169 | if (options.length === 0) { | ||
| 170 | return null; | ||
| 171 | } | ||
| 172 | return { | ||
| 173 | id: question.id, | ||
| 174 | header: question.header, | ||
| 175 | question: question.question, | ||
| 176 | options, | ||
| 177 | multiSelect: question.multiSelect === true, | ||
| 178 | }; | ||
| 179 | }) | ||
| 180 | .filter((question): question is UserInputQuestion => question !== null); | ||
| 181 | |||
| 182 | return parsed.length > 0 ? parsed : null; | ||
| 183 | } | ||
| 184 | |||
| 185 | function normalizeDraftAnswer(value: string | undefined): string | null { | ||
| 186 | if (typeof value !== "string") { | ||
| 187 | return null; | ||
| 188 | } | ||
| 189 | const trimmed = value.trim(); | ||
| 190 | return trimmed.length > 0 ? trimmed : null; | ||
| 191 | } | ||
| 192 | |||
| 193 | function resolvePendingUserInputAnswer( | ||
| 194 | draft: PendingUserInputDraftAnswer | undefined, | ||
| 195 | ): string | null { | ||
| 196 | const customAnswer = normalizeDraftAnswer(draft?.customAnswer); | ||
| 197 | if (customAnswer) { | ||
| 198 | return customAnswer; | ||
| 199 | } | ||
| 200 | return normalizeDraftAnswer(draft?.selectedOptionLabel); | ||
| 201 | } | ||
| 202 | |||
| 203 | function deriveWorkLogEntries( | ||
| 204 | activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 205 | latestTurnId: TurnId | undefined, | ||
| 206 | ): WorkLogEntry[] { | ||
| 207 | const ordered = Arr.sort(activities, activityOrder); | ||
| 208 | const entries: DerivedWorkLogEntry[] = []; | ||
| 209 | for (const activity of ordered) { | ||
| 210 | if (latestTurnId && activity.turnId !== latestTurnId) continue; | ||
| 211 | if (activity.kind === "tool.started") continue; | ||
| 212 | if (activity.kind === "task.started" || activity.kind === "task.completed") continue; | ||
| 213 | if (activity.kind === "context-window.updated") continue; | ||
| 214 | if (activity.summary === "Checkpoint captured") continue; | ||
| 215 | if (isPlanBoundaryToolActivity(activity)) continue; | ||
| 216 | entries.push(toDerivedWorkLogEntry(activity)); | ||
| 217 | } | ||
| 218 | return collapseDerivedWorkLogEntries(entries).map( | ||
| 219 | ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry, | ||
| 220 | ); | ||
| 221 | } | ||
| 222 | |||
| 223 | function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { | ||
| 224 | if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { | ||
| 225 | return false; | ||
| 226 | } | ||
| 227 | |||
| 228 | const payload = | ||
| 229 | activity.payload && typeof activity.payload === "object" | ||
| 230 | ? (activity.payload as Record<string, unknown>) | ||
| 231 | : null; | ||
| 232 | return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); | ||
| 233 | } | ||
| 234 | |||
| 235 | function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { | ||
| 236 | const payload = | ||
| 237 | activity.payload && typeof activity.payload === "object" | ||
| 238 | ? (activity.payload as Record<string, unknown>) | ||
| 239 | : null; | ||
| 240 | const commandPreview = extractToolCommand(payload); | ||
| 241 | const changedFiles = extractChangedFiles(payload); | ||
| 242 | const title = extractToolTitle(payload); | ||
| 243 | const entry: DerivedWorkLogEntry = { | ||
| 244 | id: activity.id, | ||
| 245 | createdAt: activity.createdAt, | ||
| 246 | label: activity.summary, | ||
| 247 | tone: activity.tone === "approval" ? "info" : activity.tone, | ||
| 248 | activityKind: activity.kind, | ||
| 249 | }; | ||
| 250 | const itemType = extractWorkLogItemType(payload); | ||
| 251 | const requestKind = extractWorkLogRequestKind(payload); | ||
| 252 | if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { | ||
| 253 | const detail = stripTrailingExitCode(payload.detail).output; | ||
| 254 | if (detail) { | ||
| 255 | entry.detail = detail; | ||
| 256 | } | ||
| 257 | } | ||
| 258 | if (commandPreview.command) { | ||
| 259 | entry.command = commandPreview.command; | ||
| 260 | } | ||
| 261 | if (commandPreview.rawCommand) { | ||
| 262 | entry.rawCommand = commandPreview.rawCommand; | ||
| 263 | } | ||
| 264 | if (changedFiles.length > 0) { | ||
| 265 | entry.changedFiles = changedFiles; | ||
| 266 | } | ||
| 267 | if (title) { | ||
| 268 | entry.toolTitle = title; | ||
| 269 | } | ||
| 270 | if (itemType) { | ||
| 271 | entry.itemType = itemType; | ||
| 272 | } | ||
| 273 | if (requestKind) { | ||
| 274 | entry.requestKind = requestKind; | ||
| 275 | } | ||
| 276 | const collapseKey = deriveToolLifecycleCollapseKey(entry); | ||
| 277 | if (collapseKey) { | ||
| 278 | entry.collapseKey = collapseKey; | ||
| 279 | } | ||
| 280 | return entry; | ||
| 281 | } | ||
| 282 | |||
| 283 | function collapseDerivedWorkLogEntries( | ||
| 284 | entries: ReadonlyArray<DerivedWorkLogEntry>, | ||
| 285 | ): DerivedWorkLogEntry[] { | ||
| 286 | const collapsed: DerivedWorkLogEntry[] = []; | ||
| 287 | for (const entry of entries) { | ||
| 288 | const previous = collapsed.at(-1); | ||
| 289 | if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { | ||
| 290 | collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); | ||
| 291 | continue; | ||
| 292 | } | ||
| 293 | collapsed.push(entry); | ||
| 294 | } | ||
| 295 | return collapsed; | ||
| 296 | } | ||
| 297 | |||
| 298 | function shouldCollapseToolLifecycleEntries( | ||
| 299 | previous: DerivedWorkLogEntry, | ||
| 300 | next: DerivedWorkLogEntry, | ||
| 301 | ): boolean { | ||
| 302 | if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { | ||
| 303 | return false; | ||
| 304 | } | ||
| 305 | if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { | ||
| 306 | return false; | ||
| 307 | } | ||
| 308 | if (previous.activityKind === "tool.completed") { | ||
| 309 | return false; | ||
| 310 | } | ||
| 311 | return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey; | ||
| 312 | } | ||
| 313 | |||
| 314 | function mergeDerivedWorkLogEntries( | ||
| 315 | previous: DerivedWorkLogEntry, | ||
| 316 | next: DerivedWorkLogEntry, | ||
| 317 | ): DerivedWorkLogEntry { | ||
| 318 | const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); | ||
| 319 | const detail = next.detail ?? previous.detail; | ||
| 320 | const command = next.command ?? previous.command; | ||
| 321 | const rawCommand = next.rawCommand ?? previous.rawCommand; | ||
| 322 | const toolTitle = next.toolTitle ?? previous.toolTitle; | ||
| 323 | const itemType = next.itemType ?? previous.itemType; | ||
| 324 | const requestKind = next.requestKind ?? previous.requestKind; | ||
| 325 | const collapseKey = next.collapseKey ?? previous.collapseKey; | ||
| 326 | return { | ||
| 327 | ...previous, | ||
| 328 | ...next, | ||
| 329 | ...(detail ? { detail } : {}), | ||
| 330 | ...(command ? { command } : {}), | ||
| 331 | ...(rawCommand ? { rawCommand } : {}), | ||
| 332 | ...(changedFiles.length > 0 ? { changedFiles } : {}), | ||
| 333 | ...(toolTitle ? { toolTitle } : {}), | ||
| 334 | ...(itemType ? { itemType } : {}), | ||
| 335 | ...(requestKind ? { requestKind } : {}), | ||
| 336 | ...(collapseKey ? { collapseKey } : {}), | ||
| 337 | }; | ||
| 338 | } | ||
| 339 | |||
| 340 | function mergeChangedFiles( | ||
| 341 | previous: ReadonlyArray<string> | undefined, | ||
| 342 | next: ReadonlyArray<string> | undefined, | ||
| 343 | ): string[] { | ||
| 344 | const merged = [...(previous ?? []), ...(next ?? [])]; | ||
| 345 | if (merged.length === 0) { | ||
| 346 | return []; | ||
| 347 | } | ||
| 348 | return [...new Set(merged)]; | ||
| 349 | } | ||
| 350 | |||
| 351 | function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { | ||
| 352 | if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { | ||
| 353 | return undefined; | ||
| 354 | } | ||
| 355 | const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); | ||
| 356 | const detail = entry.detail?.trim() ?? ""; | ||
| 357 | const itemType = entry.itemType ?? ""; | ||
| 358 | if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) { | ||
| 359 | return undefined; | ||
| 360 | } | ||
| 361 | return [itemType, normalizedLabel, detail].join("\u001f"); | ||
| 362 | } | ||
| 363 | |||
| 364 | function normalizeCompactToolLabel(value: string): string { | ||
| 365 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | ||
| 366 | } | ||
| 367 | |||
| 368 | function workEntryPreview( | ||
| 369 | workEntry: Pick<WorkLogEntry, "detail" | "command" | "changedFiles">, | ||
| 370 | ): string | null { | ||
| 371 | if (workEntry.command) return workEntry.command; | ||
| 372 | if (workEntry.detail) return workEntry.detail; | ||
| 373 | if ((workEntry.changedFiles?.length ?? 0) === 0) return null; | ||
| 374 | const [firstPath] = workEntry.changedFiles ?? []; | ||
| 375 | if (!firstPath) return null; | ||
| 376 | return workEntry.changedFiles!.length === 1 | ||
| 377 | ? firstPath | ||
| 378 | : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; | ||
| 379 | } | ||
| 380 | |||
| 381 | function capitalizePhrase(value: string): string { | ||
| 382 | const trimmed = value.trim(); | ||
| 383 | if (trimmed.length === 0) { | ||
| 384 | return value; | ||
| 385 | } | ||
| 386 | return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; | ||
| 387 | } | ||
| 388 | |||
| 389 | function workEntryHeading(workEntry: WorkLogEntry): string { | ||
| 390 | if (!workEntry.toolTitle) { | ||
| 391 | return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); | ||
| 392 | } | ||
| 393 | return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); | ||
| 394 | } | ||
| 395 | |||
| 396 | function asRecord(value: unknown): Record<string, unknown> | null { | ||
| 397 | return value && typeof value === "object" ? (value as Record<string, unknown>) : null; | ||
| 398 | } | ||
| 399 | |||
| 400 | function asTrimmedString(value: unknown): string | null { | ||
| 401 | if (typeof value !== "string") { | ||
| 402 | return null; | ||
| 403 | } | ||
| 404 | const trimmed = value.trim(); | ||
| 405 | return trimmed.length > 0 ? trimmed : null; | ||
| 406 | } | ||
| 407 | |||
| 408 | function trimMatchingOuterQuotes(value: string): string { | ||
| 409 | const trimmed = value.trim(); | ||
| 410 | if ( | ||
| 411 | (trimmed.startsWith("'") && trimmed.endsWith("'")) || | ||
| 412 | (trimmed.startsWith('"') && trimmed.endsWith('"')) | ||
| 413 | ) { | ||
| 414 | const unquoted = trimmed.slice(1, -1).trim(); | ||
| 415 | return unquoted.length > 0 ? unquoted : trimmed; | ||
| 416 | } | ||
| 417 | return trimmed; | ||
| 418 | } | ||
| 419 | |||
| 420 | function executableBasename(value: string): string | null { | ||
| 421 | const trimmed = trimMatchingOuterQuotes(value); | ||
| 422 | if (trimmed.length === 0) { | ||
| 423 | return null; | ||
| 424 | } | ||
| 425 | const normalized = trimmed.replace(/\\/g, "/"); | ||
| 426 | const segments = normalized.split("/"); | ||
| 427 | const last = segments.at(-1)?.trim() ?? ""; | ||
| 428 | return last.length > 0 ? last.toLowerCase() : null; | ||
| 429 | } | ||
| 430 | |||
| 431 | function splitExecutableAndRest(value: string): { executable: string; rest: string } | null { | ||
| 432 | const trimmed = value.trim(); | ||
| 433 | if (trimmed.length === 0) { | ||
| 434 | return null; | ||
| 435 | } | ||
| 436 | |||
| 437 | if (trimmed.startsWith('"') || trimmed.startsWith("'")) { | ||
| 438 | const quote = trimmed.charAt(0); | ||
| 439 | const closeIndex = trimmed.indexOf(quote, 1); | ||
| 440 | if (closeIndex <= 0) { | ||
| 441 | return null; | ||
| 442 | } | ||
| 443 | return { | ||
| 444 | executable: trimmed.slice(0, closeIndex + 1), | ||
| 445 | rest: trimmed.slice(closeIndex + 1).trim(), | ||
| 446 | }; | ||
| 447 | } | ||
| 448 | |||
| 449 | const firstWhitespace = trimmed.search(/\s/); | ||
| 450 | if (firstWhitespace < 0) { | ||
| 451 | return { | ||
| 452 | executable: trimmed, | ||
| 453 | rest: "", | ||
| 454 | }; | ||
| 455 | } | ||
| 456 | |||
| 457 | return { | ||
| 458 | executable: trimmed.slice(0, firstWhitespace), | ||
| 459 | rest: trimmed.slice(firstWhitespace).trim(), | ||
| 460 | }; | ||
| 461 | } | ||
| 462 | |||
| 463 | const SHELL_WRAPPER_SPECS = [ | ||
| 464 | { | ||
| 465 | executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"], | ||
| 466 | wrapperFlagPattern: /(?:^|\s)-command\s+/i, | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | executables: ["cmd", "cmd.exe"], | ||
| 470 | wrapperFlagPattern: /(?:^|\s)\/c\s+/i, | ||
| 471 | }, | ||
| 472 | { | ||
| 473 | executables: ["bash", "sh", "zsh"], | ||
| 474 | wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i, | ||
| 475 | }, | ||
| 476 | ] as const; | ||
| 477 | |||
| 478 | function findShellWrapperSpec(shell: string) { | ||
| 479 | return SHELL_WRAPPER_SPECS.find((spec) => | ||
| 480 | (spec.executables as ReadonlyArray<string>).includes(shell), | ||
| 481 | ); | ||
| 482 | } | ||
| 483 | |||
| 484 | function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null { | ||
| 485 | const match = wrapperFlagPattern.exec(value); | ||
| 486 | if (!match) { | ||
| 487 | return null; | ||
| 488 | } | ||
| 489 | |||
| 490 | const command = value.slice(match.index + match[0].length).trim(); | ||
| 491 | if (command.length === 0) { | ||
| 492 | return null; | ||
| 493 | } | ||
| 494 | |||
| 495 | const unwrapped = trimMatchingOuterQuotes(command); | ||
| 496 | return unwrapped.length > 0 ? unwrapped : null; | ||
| 497 | } | ||
| 498 | |||
| 499 | function unwrapKnownShellCommandWrapper(value: string): string { | ||
| 500 | const split = splitExecutableAndRest(value); | ||
| 501 | if (!split || split.rest.length === 0) { | ||
| 502 | return value; | ||
| 503 | } | ||
| 504 | |||
| 505 | const shell = executableBasename(split.executable); | ||
| 506 | if (!shell) { | ||
| 507 | return value; | ||
| 508 | } | ||
| 509 | |||
| 510 | const spec = findShellWrapperSpec(shell); | ||
| 511 | if (!spec) { | ||
| 512 | return value; | ||
| 513 | } | ||
| 514 | |||
| 515 | return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value; | ||
| 516 | } | ||
| 517 | |||
| 518 | function formatCommandArrayPart(value: string): string { | ||
| 519 | return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; | ||
| 520 | } | ||
| 521 | |||
| 522 | function formatCommandValue(value: unknown): string | null { | ||
| 523 | const direct = asTrimmedString(value); | ||
| 524 | if (direct) { | ||
| 525 | return direct; | ||
| 526 | } | ||
| 527 | if (!Array.isArray(value)) { | ||
| 528 | return null; | ||
| 529 | } | ||
| 530 | const parts: Array<string> = []; | ||
| 531 | for (const entry of value) { | ||
| 532 | const part = asTrimmedString(entry); | ||
| 533 | if (part !== null) { | ||
| 534 | parts.push(part); | ||
| 535 | } | ||
| 536 | } | ||
| 537 | if (parts.length === 0) { | ||
| 538 | return null; | ||
| 539 | } | ||
| 540 | return parts.map((part) => formatCommandArrayPart(part)).join(" "); | ||
| 541 | } | ||
| 542 | |||
| 543 | function normalizeCommandValue(value: unknown): string | null { | ||
| 544 | const formatted = formatCommandValue(value); | ||
| 545 | return formatted ? unwrapKnownShellCommandWrapper(formatted) : null; | ||
| 546 | } | ||
| 547 | |||
| 548 | function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null { | ||
| 549 | const formatted = formatCommandValue(value); | ||
| 550 | if (!formatted || normalizedCommand === null) { | ||
| 551 | return null; | ||
| 552 | } | ||
| 553 | return formatted === normalizedCommand ? null : formatted; | ||
| 554 | } | ||
| 555 | |||
| 556 | function extractToolCommand(payload: Record<string, unknown> | null): { | ||
| 557 | command: string | null; | ||
| 558 | rawCommand: string | null; | ||
| 559 | } { | ||
| 560 | const data = asRecord(payload?.data); | ||
| 561 | const item = asRecord(data?.item); | ||
| 562 | const itemResult = asRecord(item?.result); | ||
| 563 | const itemInput = asRecord(item?.input); | ||
| 564 | const itemType = asTrimmedString(payload?.itemType); | ||
| 565 | const detail = asTrimmedString(payload?.detail); | ||
| 566 | const candidates: unknown[] = [ | ||
| 567 | item?.command, | ||
| 568 | itemInput?.command, | ||
| 569 | itemResult?.command, | ||
| 570 | data?.command, | ||
| 571 | itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, | ||
| 572 | ]; | ||
| 573 | |||
| 574 | for (const candidate of candidates) { | ||
| 575 | const command = normalizeCommandValue(candidate); | ||
| 576 | if (!command) { | ||
| 577 | continue; | ||
| 578 | } | ||
| 579 | return { | ||
| 580 | command, | ||
| 581 | rawCommand: toRawToolCommand(candidate, command), | ||
| 582 | }; | ||
| 583 | } | ||
| 584 | |||
| 585 | return { | ||
| 586 | command: null, | ||
| 587 | rawCommand: null, | ||
| 588 | }; | ||
| 589 | } | ||
| 590 | |||
| 591 | function extractToolTitle(payload: Record<string, unknown> | null): string | null { | ||
| 592 | return asTrimmedString(payload?.title); | ||
| 593 | } | ||
| 594 | |||
| 595 | function stripTrailingExitCode(value: string): { | ||
| 596 | output: string | null; | ||
| 597 | exitCode?: number | undefined; | ||
| 598 | } { | ||
| 599 | const trimmed = value.trim(); | ||
| 600 | const match = /^(?<output>[\s\S]*?)(?:\s*<exited with exit code (?<code>\d+)>)\s*$/i.exec( | ||
| 601 | trimmed, | ||
| 602 | ); | ||
| 603 | if (!match?.groups) { | ||
| 604 | return { | ||
| 605 | output: trimmed.length > 0 ? trimmed : null, | ||
| 606 | }; | ||
| 607 | } | ||
| 608 | const exitCode = Number.parseInt(match.groups.code ?? "", 10); | ||
| 609 | const normalizedOutput = match.groups.output?.trim() ?? ""; | ||
| 610 | return { | ||
| 611 | output: normalizedOutput.length > 0 ? normalizedOutput : null, | ||
| 612 | ...(Number.isInteger(exitCode) ? { exitCode } : {}), | ||
| 613 | }; | ||
| 614 | } | ||
| 615 | |||
| 616 | function extractWorkLogItemType( | ||
| 617 | payload: Record<string, unknown> | null, | ||
| 618 | ): WorkLogEntry["itemType"] | undefined { | ||
| 619 | if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) { | ||
| 620 | return payload.itemType; | ||
| 621 | } | ||
| 622 | return undefined; | ||
| 623 | } | ||
| 624 | |||
| 625 | function extractWorkLogRequestKind( | ||
| 626 | payload: Record<string, unknown> | null, | ||
| 627 | ): WorkLogEntry["requestKind"] | undefined { | ||
| 628 | if ( | ||
| 629 | payload?.requestKind === "command" || | ||
| 630 | payload?.requestKind === "file-read" || | ||
| 631 | payload?.requestKind === "file-change" | ||
| 632 | ) { | ||
| 633 | return payload.requestKind; | ||
| 634 | } | ||
| 635 | return requestKindFromRequestType(payload?.requestType) ?? undefined; | ||
| 636 | } | ||
| 637 | |||
| 638 | function pushChangedFile(target: string[], seen: Set<string>, value: unknown) { | ||
| 639 | const normalized = asTrimmedString(value); | ||
| 640 | if (!normalized || seen.has(normalized)) { | ||
| 641 | return; | ||
| 642 | } | ||
| 643 | seen.add(normalized); | ||
| 644 | target.push(normalized); | ||
| 645 | } | ||
| 646 | |||
| 647 | function collectChangedFiles(value: unknown, target: string[], seen: Set<string>, depth: number) { | ||
| 648 | if (depth > 4 || target.length >= 12) { | ||
| 649 | return; | ||
| 650 | } | ||
| 651 | if (Array.isArray(value)) { | ||
| 652 | for (const entry of value) { | ||
| 653 | collectChangedFiles(entry, target, seen, depth + 1); | ||
| 654 | if (target.length >= 12) { | ||
| 655 | return; | ||
| 656 | } | ||
| 657 | } | ||
| 658 | return; | ||
| 659 | } | ||
| 660 | |||
| 661 | const record = asRecord(value); | ||
| 662 | if (!record) { | ||
| 663 | return; | ||
| 664 | } | ||
| 665 | |||
| 666 | pushChangedFile(target, seen, record.path); | ||
| 667 | pushChangedFile(target, seen, record.filePath); | ||
| 668 | pushChangedFile(target, seen, record.relativePath); | ||
| 669 | pushChangedFile(target, seen, record.filename); | ||
| 670 | pushChangedFile(target, seen, record.newPath); | ||
| 671 | pushChangedFile(target, seen, record.oldPath); | ||
| 672 | |||
| 673 | for (const nestedKey of [ | ||
| 674 | "item", | ||
| 675 | "result", | ||
| 676 | "input", | ||
| 677 | "data", | ||
| 678 | "changes", | ||
| 679 | "files", | ||
| 680 | "edits", | ||
| 681 | "patch", | ||
| 682 | "patches", | ||
| 683 | "operations", | ||
| 684 | ]) { | ||
| 685 | if (!(nestedKey in record)) { | ||
| 686 | continue; | ||
| 687 | } | ||
| 688 | collectChangedFiles(record[nestedKey], target, seen, depth + 1); | ||
| 689 | if (target.length >= 12) { | ||
| 690 | return; | ||
| 691 | } | ||
| 692 | } | ||
| 693 | } | ||
| 694 | |||
| 695 | function extractChangedFiles(payload: Record<string, unknown> | null): string[] { | ||
| 696 | const changedFiles: string[] = []; | ||
| 697 | const seen = new Set<string>(); | ||
| 698 | collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0); | ||
| 699 | return changedFiles; | ||
| 700 | } | ||
| 701 | |||
| 702 | function compareActivityLifecycleRank(kind: string): number { | ||
| 703 | if (kind.endsWith(".started") || kind === "tool.started") { | ||
| 704 | return 0; | ||
| 705 | } | ||
| 706 | if (kind.endsWith(".progress") || kind.endsWith(".updated")) { | ||
| 707 | return 1; | ||
| 708 | } | ||
| 709 | if (kind.endsWith(".completed") || kind.endsWith(".resolved")) { | ||
| 710 | return 2; | ||
| 711 | } | ||
| 712 | return 1; | ||
| 713 | } | ||
| 714 | |||
| 715 | const activityOrder = Order.combineAll<OrchestrationThreadActivity>([ | ||
| 716 | Order.mapInput(Order.Number, (activity) => activity.sequence ?? Number.MAX_SAFE_INTEGER), | ||
| 717 | Order.mapInput(Order.String, (activity) => activity.createdAt), | ||
| 718 | Order.mapInput(Order.Number, (activity) => compareActivityLifecycleRank(activity.kind)), | ||
| 719 | Order.mapInput(Order.String, (activity) => activity.id), | ||
| 720 | ]); | ||
| 721 | |||
| 722 | function isEmptyMessage(entry: RawThreadFeedEntry): boolean { | ||
| 723 | if (entry.type !== "message") { | ||
| 724 | return false; | ||
| 725 | } | ||
| 726 | const hasText = entry.message.text.trim().length > 0; | ||
| 727 | const hasAttachments = (entry.message.attachments ?? []).length > 0; | ||
| 728 | return !hasText && !hasAttachments; | ||
| 729 | } | ||
| 730 | |||
| 731 | function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): ThreadFeedEntry[] { | ||
| 732 | const grouped: ThreadFeedEntry[] = []; | ||
| 733 | |||
| 734 | for (const entry of entries) { | ||
| 735 | // Skip empty messages so they don't break activity grouping. | ||
| 736 | if (isEmptyMessage(entry)) { | ||
| 737 | continue; | ||
| 738 | } | ||
| 739 | |||
| 740 | if (entry.type !== "activity") { | ||
| 741 | grouped.push(entry); | ||
| 742 | continue; | ||
| 743 | } | ||
| 744 | |||
| 745 | const previous = grouped.at(-1); | ||
| 746 | if (previous?.type === "activity-group") { | ||
| 747 | grouped[grouped.length - 1] = { | ||
| 748 | ...previous, | ||
| 749 | activities: [...previous.activities, entry.activity], | ||
| 750 | }; | ||
| 751 | continue; | ||
| 752 | } | ||
| 753 | |||
| 754 | grouped.push({ | ||
| 755 | type: "activity-group", | ||
| 756 | id: entry.id, | ||
| 757 | createdAt: entry.createdAt, | ||
| 758 | activities: [entry.activity], | ||
| 759 | }); | ||
| 760 | } | ||
| 761 | |||
| 762 | return grouped; | ||
| 763 | } | ||
| 764 | |||
| 765 | export function derivePendingApprovals( | ||
| 766 | activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 767 | ): PendingApproval[] { | ||
| 768 | const openByRequestId = new Map<ApprovalRequestId, PendingApproval>(); | ||
| 769 | const ordered = Arr.sort(activities, activityOrder); | ||
| 770 | |||
| 771 | for (const activity of ordered) { | ||
| 772 | const payload = | ||
| 773 | activity.payload && typeof activity.payload === "object" | ||
| 774 | ? (activity.payload as Record<string, unknown>) | ||
| 775 | : null; | ||
| 776 | const requestId = parseApprovalRequestId(payload?.requestId); | ||
| 777 | const requestKind = | ||
| 778 | payload?.requestKind === "command" || | ||
| 779 | payload?.requestKind === "file-read" || | ||
| 780 | payload?.requestKind === "file-change" | ||
| 781 | ? payload.requestKind | ||
| 782 | : requestKindFromRequestType(payload?.requestType); | ||
| 783 | const detail = typeof payload?.detail === "string" ? payload.detail : undefined; | ||
| 784 | |||
| 785 | if (activity.kind === "approval.requested" && requestId && requestKind) { | ||
| 786 | openByRequestId.set(requestId, { | ||
| 787 | requestId, | ||
| 788 | requestKind, | ||
| 789 | createdAt: activity.createdAt, | ||
| 790 | ...(detail ? { detail } : {}), | ||
| 791 | }); | ||
| 792 | continue; | ||
| 793 | } | ||
| 794 | |||
| 795 | if (activity.kind === "approval.resolved" && requestId) { | ||
| 796 | openByRequestId.delete(requestId); | ||
| 797 | continue; | ||
| 798 | } | ||
| 799 | |||
| 800 | if ( | ||
| 801 | activity.kind === "provider.approval.respond.failed" && | ||
| 802 | requestId && | ||
| 803 | isStalePendingRequestFailureDetail(detail) | ||
| 804 | ) { | ||
| 805 | openByRequestId.delete(requestId); | ||
| 806 | } | ||
| 807 | } | ||
| 808 | |||
| 809 | return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date); | ||
| 810 | } | ||
| 811 | |||
| 812 | export function derivePendingUserInputs( | ||
| 813 | activities: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 814 | ): PendingUserInput[] { | ||
| 815 | const openByRequestId = new Map<ApprovalRequestId, PendingUserInput>(); | ||
| 816 | const ordered = Arr.sort(activities, activityOrder); | ||
| 817 | |||
| 818 | for (const activity of ordered) { | ||
| 819 | const payload = | ||
| 820 | activity.payload && typeof activity.payload === "object" | ||
| 821 | ? (activity.payload as Record<string, unknown>) | ||
| 822 | : null; | ||
| 823 | const requestId = parseApprovalRequestId(payload?.requestId); | ||
| 824 | const detail = typeof payload?.detail === "string" ? payload.detail : undefined; | ||
| 825 | |||
| 826 | if (activity.kind === "user-input.requested" && requestId) { | ||
| 827 | const questions = parseUserInputQuestions(payload); | ||
| 828 | if (!questions) { | ||
| 829 | continue; | ||
| 830 | } | ||
| 831 | openByRequestId.set(requestId, { | ||
| 832 | requestId, | ||
| 833 | createdAt: activity.createdAt, | ||
| 834 | questions, | ||
| 835 | }); | ||
| 836 | continue; | ||
| 837 | } | ||
| 838 | |||
| 839 | if (activity.kind === "user-input.resolved" && requestId) { | ||
| 840 | openByRequestId.delete(requestId); | ||
| 841 | continue; | ||
| 842 | } | ||
| 843 | |||
| 844 | if ( | ||
| 845 | activity.kind === "provider.user-input.respond.failed" && | ||
| 846 | requestId && | ||
| 847 | isStalePendingRequestFailureDetail(detail) | ||
| 848 | ) { | ||
| 849 | openByRequestId.delete(requestId); | ||
| 850 | } | ||
| 851 | } | ||
| 852 | |||
| 853 | return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date); | ||
| 854 | } | ||
| 855 | |||
| 856 | export function setPendingUserInputCustomAnswer( | ||
| 857 | draft: PendingUserInputDraftAnswer | undefined, | ||
| 858 | customAnswer: string, | ||
| 859 | ): PendingUserInputDraftAnswer { | ||
| 860 | const selectedOptionLabel = | ||
| 861 | customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; | ||
| 862 | return { | ||
| 863 | customAnswer, | ||
| 864 | ...(selectedOptionLabel ? { selectedOptionLabel } : {}), | ||
| 865 | }; | ||
| 866 | } | ||
| 867 | |||
| 868 | export function buildPendingUserInputAnswers( | ||
| 869 | questions: ReadonlyArray<UserInputQuestion>, | ||
| 870 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | ||
| 871 | ): Record<string, string> | null { | ||
| 872 | const answers: Record<string, string> = {}; | ||
| 873 | |||
| 874 | for (const question of questions) { | ||
| 875 | const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); | ||
| 876 | if (!answer) { | ||
| 877 | return null; | ||
| 878 | } | ||
| 879 | answers[question.id] = answer; | ||
| 880 | } | ||
| 881 | |||
| 882 | return answers; | ||
| 883 | } | ||
| 884 | |||
| 885 | export function buildThreadFeed( | ||
| 886 | thread: OrchestrationThread, | ||
| 887 | queuedMessages: ReadonlyArray<QueuedThreadMessage>, | ||
| 888 | dispatchingQueuedMessageId: MessageId | null, | ||
| 889 | options?: { | ||
| 890 | readonly loadedMessages?: ReadonlyArray<OrchestrationThread["messages"][number]>; | ||
| 891 | }, | ||
| 892 | ): ThreadFeedEntry[] { | ||
| 893 | const loadedMessages = options?.loadedMessages ?? thread.messages; | ||
| 894 | const oldestLoadedMessageCreatedAt = | ||
| 895 | options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; | ||
| 896 | const workLogEntries = deriveWorkLogEntries( | ||
| 897 | thread.activities, | ||
| 898 | thread.latestTurn?.turnId ?? undefined, | ||
| 899 | ); | ||
| 900 | const entries = Arr.sortWith( | ||
| 901 | [ | ||
| 902 | ...loadedMessages.map<RawThreadFeedEntry>((message) => ({ | ||
| 903 | type: "message", | ||
| 904 | id: message.id, | ||
| 905 | createdAt: message.createdAt, | ||
| 906 | message, | ||
| 907 | })), | ||
| 908 | ...queuedMessages.map<RawThreadFeedEntry>((queuedMessage) => ({ | ||
| 909 | type: "queued-message", | ||
| 910 | id: queuedMessage.messageId, | ||
| 911 | createdAt: queuedMessage.createdAt, | ||
| 912 | queuedMessage, | ||
| 913 | sending: queuedMessage.messageId === dispatchingQueuedMessageId, | ||
| 914 | })), | ||
| 915 | ...workLogEntries | ||
| 916 | .filter((entry) => { | ||
| 917 | if (options?.loadedMessages === undefined) { | ||
| 918 | return true; | ||
| 919 | } | ||
| 920 | return ( | ||
| 921 | oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt | ||
| 922 | ); | ||
| 923 | }) | ||
| 924 | .map<RawThreadFeedEntry>((entry) => ({ | ||
| 925 | type: "activity", | ||
| 926 | id: entry.id, | ||
| 927 | createdAt: entry.createdAt, | ||
| 928 | activity: { | ||
| 929 | id: entry.id, | ||
| 930 | createdAt: entry.createdAt, | ||
| 931 | summary: workEntryHeading(entry), | ||
| 932 | detail: workEntryPreview(entry), | ||
| 933 | status: null, | ||
| 934 | }, | ||
| 935 | })), | ||
| 936 | ], | ||
| 937 | (s) => new Date(s.createdAt), | ||
| 938 | Order.Date, | ||
| 939 | ); | ||
| 940 | |||
| 941 | return groupAdjacentActivities(entries); | ||
| 942 | } | ||
apps/mobile/src/lib/time.ts created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | export function relativeTime(input: string): string { | ||
| 2 | const timestamp = Date.parse(input); | ||
| 3 | if (Number.isNaN(timestamp)) { | ||
| 4 | return "now"; | ||
| 5 | } | ||
| 6 | |||
| 7 | const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); | ||
| 8 | if (deltaSeconds < 10) return "now"; | ||
| 9 | if (deltaSeconds < 60) return `${deltaSeconds}s`; | ||
| 10 | |||
| 11 | const deltaMinutes = Math.floor(deltaSeconds / 60); | ||
| 12 | if (deltaMinutes < 60) return `${deltaMinutes}m`; | ||
| 13 | |||
| 14 | const deltaHours = Math.floor(deltaMinutes / 60); | ||
| 15 | if (deltaHours < 24) return `${deltaHours}h`; | ||
| 16 | |||
| 17 | const deltaDays = Math.floor(deltaHours / 24); | ||
| 18 | return `${deltaDays}d`; | ||
| 19 | } | ||
apps/mobile/src/lib/useNativePaste.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import type { PasteEventPayload } from "expo-paste-input"; | ||
| 2 | import { useCallback } from "react"; | ||
| 3 | |||
| 4 | /** | ||
| 5 | * Returns a stable `onPaste` handler for `TextInputWrapper` from | ||
| 6 | * `expo-paste-input`. When the user pastes images via the OS paste gesture, | ||
| 7 | * `onImages` is called with the pasted URIs. Text pastes are left to the | ||
| 8 | * native TextInput — no extra handling is needed. | ||
| 9 | * | ||
| 10 | * Used by both the thread composer and the new-task draft screen. | ||
| 11 | */ | ||
| 12 | export function useNativePaste(onImages: (uris: ReadonlyArray<string>) => void) { | ||
| 13 | return useCallback( | ||
| 14 | (payload: PasteEventPayload) => { | ||
| 15 | if (payload.type === "images" && payload.uris && payload.uris.length > 0) { | ||
| 16 | onImages(payload.uris); | ||
| 17 | } | ||
| 18 | }, | ||
| 19 | [onImages], | ||
| 20 | ); | ||
| 21 | } | ||
apps/mobile/src/lib/useThemeColor.ts created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import type { ColorValue } from "react-native"; | ||
| 2 | import { useCSSVariable } from "uniwind"; | ||
| 3 | |||
| 4 | /** | ||
| 5 | * Typed wrapper around `useCSSVariable` that returns a `ColorValue` for use | ||
| 6 | * in React Native style props (backgroundColor, tintColor, etc.). | ||
| 7 | * | ||
| 8 | * Usage: `const color = useThemeColor("--color-icon");` | ||
| 9 | */ | ||
| 10 | export function useThemeColor(variable: `--color-${string}`): ColorValue { | ||
| 11 | return useCSSVariable(variable) as string as ColorValue; | ||
| 12 | } | ||
apps/mobile/src/lib/uuid.ts created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | import * as Crypto from "expo-crypto"; | ||
| 2 | |||
| 3 | export const uuidv4 = () => Crypto.randomUUID(); | ||
apps/mobile/src/state/atom-registry.ts created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | |||
| 3 | export const appAtomRegistry = AtomRegistry.make(); | ||
apps/mobile/src/state/environment-session-registry.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import type { EnvironmentSession } from "./remote-runtime-types"; | ||
| 4 | |||
| 5 | const environmentSessions = new Map<EnvironmentId, EnvironmentSession>(); | ||
| 6 | const environmentConnectionListeners = new Set<() => void>(); | ||
| 7 | |||
| 8 | export function getEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { | ||
| 9 | return environmentSessions.get(environmentId) ?? null; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function getEnvironmentClient(environmentId: EnvironmentId) { | ||
| 13 | return getEnvironmentSession(environmentId)?.client ?? null; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function setEnvironmentSession( | ||
| 17 | environmentId: EnvironmentId, | ||
| 18 | session: EnvironmentSession, | ||
| 19 | ): void { | ||
| 20 | environmentSessions.set(environmentId, session); | ||
| 21 | } | ||
| 22 | |||
| 23 | export function removeEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { | ||
| 24 | const session = getEnvironmentSession(environmentId); | ||
| 25 | environmentSessions.delete(environmentId); | ||
| 26 | return session; | ||
| 27 | } | ||
| 28 | |||
| 29 | export function drainEnvironmentSessions(): ReadonlyArray<EnvironmentSession> { | ||
| 30 | const sessions = [...environmentSessions.values()]; | ||
| 31 | environmentSessions.clear(); | ||
| 32 | return sessions; | ||
| 33 | } | ||
| 34 | |||
| 35 | export function notifyEnvironmentConnectionListeners() { | ||
| 36 | for (const listener of environmentConnectionListeners) listener(); | ||
| 37 | } | ||
| 38 | |||
| 39 | /** | ||
| 40 | * Subscribe to environment-connection changes (connect / disconnect / reconnect). | ||
| 41 | * Returns an unsubscribe function. | ||
| 42 | */ | ||
| 43 | export function subscribeEnvironmentConnections(listener: () => void): () => void { | ||
| 44 | environmentConnectionListeners.add(listener); | ||
| 45 | return () => { | ||
| 46 | environmentConnectionListeners.delete(listener); | ||
| 47 | }; | ||
| 48 | } | ||
apps/mobile/src/state/remote-runtime-types.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentConnection, | ||
| 3 | EnvironmentConnectionState, | ||
| 4 | WsRpcClient, | ||
| 5 | } from "@t3tools/client-runtime"; | ||
| 6 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | export type { EnvironmentRuntimeState } from "@t3tools/client-runtime"; | ||
| 9 | |||
| 10 | export interface ConnectedEnvironmentSummary { | ||
| 11 | readonly environmentId: EnvironmentId; | ||
| 12 | readonly environmentLabel: string; | ||
| 13 | readonly displayUrl: string; | ||
| 14 | readonly connectionState: EnvironmentConnectionState; | ||
| 15 | readonly connectionError: string | null; | ||
| 16 | } | ||
| 17 | |||
| 18 | export interface SelectedThreadRef { | ||
| 19 | readonly environmentId: EnvironmentId; | ||
| 20 | readonly threadId: ThreadId; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface EnvironmentSession { | ||
| 24 | readonly client: WsRpcClient; | ||
| 25 | readonly connection: EnvironmentConnection; | ||
| 26 | } | ||
apps/mobile/src/state/use-checkpoint-diff.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { createCheckpointDiffManager, type CheckpointDiffTarget } from "@t3tools/client-runtime"; | ||
| 2 | |||
| 3 | import { appAtomRegistry } from "./atom-registry"; | ||
| 4 | import { getEnvironmentClient } from "./environment-session-registry"; | ||
| 5 | |||
| 6 | export const checkpointDiffManager = createCheckpointDiffManager({ | ||
| 7 | getRegistry: () => appAtomRegistry, | ||
| 8 | getClient: (environmentId) => getEnvironmentClient(environmentId)?.orchestration ?? null, | ||
| 9 | }); | ||
| 10 | |||
| 11 | export function loadCheckpointDiff( | ||
| 12 | target: CheckpointDiffTarget, | ||
| 13 | options?: { readonly force?: boolean }, | ||
| 14 | ) { | ||
| 15 | return checkpointDiffManager.load(target, undefined, options); | ||
| 16 | } | ||
apps/mobile/src/state/use-composer-path-search.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 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 COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; | ||
| 21 | |||
| 22 | const composerPathSearchManager = createComposerPathSearchManager({ | ||
| 23 | getRegistry: () => appAtomRegistry, | ||
| 24 | getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null, | ||
| 25 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 26 | staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, | ||
| 27 | }); | ||
| 28 | |||
| 29 | export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { | ||
| 30 | const stableTarget = useMemo( | ||
| 31 | () => ({ | ||
| 32 | environmentId: target.environmentId, | ||
| 33 | cwd: target.cwd, | ||
| 34 | query: normalizeComposerPathSearchQuery(target.query), | ||
| 35 | }), | ||
| 36 | [target.cwd, target.environmentId, target.query], | ||
| 37 | ); | ||
| 38 | const targetKey = getComposerPathSearchTargetKey(stableTarget); | ||
| 39 | |||
| 40 | useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); | ||
| 41 | |||
| 42 | const state = useAtomValue( | ||
| 43 | targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, | ||
| 44 | ); | ||
| 45 | return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; | ||
| 46 | } | ||
apps/mobile/src/state/use-environment-runtime.ts created+76| ... | @@ -0,0 +1,76 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | EMPTY_ENVIRONMENT_RUNTIME_ATOM, | ||
| 4 | EMPTY_ENVIRONMENT_RUNTIME_STATE, | ||
| 5 | createEnvironmentRuntimeManager, | ||
| 6 | environmentRuntimeStateAtom, | ||
| 7 | getEnvironmentRuntimeTargetKey, | ||
| 8 | type EnvironmentRuntimeState, | ||
| 9 | } from "@t3tools/client-runtime"; | ||
| 10 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 11 | import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; | ||
| 12 | |||
| 13 | import { appAtomRegistry } from "./atom-registry"; | ||
| 14 | import * as Arr from "effect/Array"; | ||
| 15 | import * as Order from "effect/Order"; | ||
| 16 | |||
| 17 | export const environmentRuntimeManager = createEnvironmentRuntimeManager({ | ||
| 18 | getRegistry: () => appAtomRegistry, | ||
| 19 | }); | ||
| 20 | |||
| 21 | export function useEnvironmentRuntime( | ||
| 22 | environmentId: EnvironmentId | null, | ||
| 23 | ): EnvironmentRuntimeState { | ||
| 24 | const targetKey = getEnvironmentRuntimeTargetKey({ environmentId }); | ||
| 25 | const state = useAtomValue( | ||
| 26 | targetKey !== null ? environmentRuntimeStateAtom(targetKey) : EMPTY_ENVIRONMENT_RUNTIME_ATOM, | ||
| 27 | ); | ||
| 28 | return targetKey === null ? EMPTY_ENVIRONMENT_RUNTIME_STATE : state; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function useEnvironmentRuntimeStates( | ||
| 32 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 33 | ): Readonly<Record<EnvironmentId, EnvironmentRuntimeState>> { | ||
| 34 | const stableEnvironmentIds = useMemo( | ||
| 35 | () => Arr.sort(new Set(environmentIds), Order.String), | ||
| 36 | [environmentIds], | ||
| 37 | ); | ||
| 38 | const snapshotCacheRef = useRef<Readonly<Record<EnvironmentId, EnvironmentRuntimeState>>>({}); | ||
| 39 | |||
| 40 | const subscribe = useCallback( | ||
| 41 | (onStoreChange: () => void) => { | ||
| 42 | const unsubs = stableEnvironmentIds.map((environmentId) => | ||
| 43 | appAtomRegistry.subscribe(environmentRuntimeStateAtom(environmentId), onStoreChange), | ||
| 44 | ); | ||
| 45 | return () => { | ||
| 46 | for (const unsub of unsubs) { | ||
| 47 | unsub(); | ||
| 48 | } | ||
| 49 | }; | ||
| 50 | }, | ||
| 51 | [stableEnvironmentIds], | ||
| 52 | ); | ||
| 53 | |||
| 54 | const getSnapshot = useCallback(() => { | ||
| 55 | const previous = snapshotCacheRef.current; | ||
| 56 | let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; | ||
| 57 | const next: Record<EnvironmentId, EnvironmentRuntimeState> = {}; | ||
| 58 | |||
| 59 | for (const environmentId of stableEnvironmentIds) { | ||
| 60 | const snapshot = environmentRuntimeManager.getSnapshot({ environmentId }); | ||
| 61 | next[environmentId] = snapshot; | ||
| 62 | if (!hasChanged && previous[environmentId] !== snapshot) { | ||
| 63 | hasChanged = true; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | if (!hasChanged) { | ||
| 68 | return previous; | ||
| 69 | } | ||
| 70 | |||
| 71 | snapshotCacheRef.current = next; | ||
| 72 | return next; | ||
| 73 | }, [stableEnvironmentIds]); | ||
| 74 | |||
| 75 | return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); | ||
| 76 | } | ||
apps/mobile/src/state/use-filesystem-browse.ts created+82| ... | @@ -0,0 +1,82 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | EMPTY_FILESYSTEM_BROWSE_ATOM, | ||
| 4 | EMPTY_FILESYSTEM_BROWSE_STATE, | ||
| 5 | type FilesystemBrowseClient, | ||
| 6 | type FilesystemBrowseState, | ||
| 7 | type FilesystemBrowseTarget, | ||
| 8 | createFilesystemBrowseManager, | ||
| 9 | filesystemBrowseStateAtom, | ||
| 10 | getFilesystemBrowseTargetKey, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | import type { | ||
| 13 | EnvironmentId, | ||
| 14 | FilesystemBrowseInput, | ||
| 15 | FilesystemBrowseResult, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import { useEffect, useMemo } from "react"; | ||
| 18 | |||
| 19 | import { appAtomRegistry } from "./atom-registry"; | ||
| 20 | import { | ||
| 21 | getEnvironmentClient, | ||
| 22 | subscribeEnvironmentConnections, | ||
| 23 | } from "./environment-session-registry"; | ||
| 24 | |||
| 25 | const filesystemBrowseManager = createFilesystemBrowseManager<EnvironmentId>({ | ||
| 26 | getRegistry: () => appAtomRegistry, | ||
| 27 | getClient: (environmentId) => getEnvironmentClient(environmentId)?.filesystem ?? null, | ||
| 28 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 29 | }); | ||
| 30 | |||
| 31 | function filesystemBrowseTargetForEnvironment( | ||
| 32 | environmentId: EnvironmentId | null, | ||
| 33 | input: FilesystemBrowseInput | null, | ||
| 34 | ): FilesystemBrowseTarget<EnvironmentId> { | ||
| 35 | return { key: environmentId, input }; | ||
| 36 | } | ||
| 37 | |||
| 38 | export function refreshFilesystemBrowseForEnvironment( | ||
| 39 | environmentId: EnvironmentId | null, | ||
| 40 | input: FilesystemBrowseInput | null, | ||
| 41 | client?: FilesystemBrowseClient | null, | ||
| 42 | ): Promise<FilesystemBrowseResult | null> { | ||
| 43 | return filesystemBrowseManager.refresh( | ||
| 44 | filesystemBrowseTargetForEnvironment(environmentId, input), | ||
| 45 | client ?? undefined, | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | export function invalidateFilesystemBrowseForEnvironment( | ||
| 50 | environmentId: EnvironmentId | null, | ||
| 51 | input: FilesystemBrowseInput | null, | ||
| 52 | ): void { | ||
| 53 | filesystemBrowseManager.invalidate(filesystemBrowseTargetForEnvironment(environmentId, input)); | ||
| 54 | } | ||
| 55 | |||
| 56 | export function resetFilesystemBrowseState(): void { | ||
| 57 | filesystemBrowseManager.reset(); | ||
| 58 | } | ||
| 59 | |||
| 60 | export function resetFilesystemBrowseStateForTests(): void { | ||
| 61 | resetFilesystemBrowseState(); | ||
| 62 | } | ||
| 63 | |||
| 64 | export function useFilesystemBrowse( | ||
| 65 | environmentId: EnvironmentId | null, | ||
| 66 | input: FilesystemBrowseInput | null, | ||
| 67 | ): FilesystemBrowseState { | ||
| 68 | const target = useMemo( | ||
| 69 | () => filesystemBrowseTargetForEnvironment(environmentId, input), | ||
| 70 | [environmentId, input], | ||
| 71 | ); | ||
| 72 | |||
| 73 | useEffect(() => { | ||
| 74 | return filesystemBrowseManager.watch(target); | ||
| 75 | }, [target]); | ||
| 76 | |||
| 77 | const targetKey = getFilesystemBrowseTargetKey(target); | ||
| 78 | const state = useAtomValue( | ||
| 79 | targetKey !== null ? filesystemBrowseStateAtom(targetKey) : EMPTY_FILESYSTEM_BROWSE_ATOM, | ||
| 80 | ); | ||
| 81 | return targetKey === null ? EMPTY_FILESYSTEM_BROWSE_STATE : state; | ||
| 82 | } | ||
apps/mobile/src/state/use-remote-catalog.ts created+198| ... | @@ -0,0 +1,198 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | import * as Order from "effect/Order"; | ||
| 3 | import * as Arr from "effect/Array"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | EnvironmentConnectionState, | ||
| 7 | EnvironmentScopedProjectShell, | ||
| 8 | EnvironmentScopedThreadShell, | ||
| 9 | scopeProjectShell, | ||
| 10 | scopeThreadShell, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | |||
| 13 | import { ConnectedEnvironmentSummary } from "./remote-runtime-types"; | ||
| 14 | import type { SavedRemoteConnection } from "../lib/connection"; | ||
| 15 | import { useCachedShellSnapshotMetadata, useShellSnapshotStates } from "./use-shell-snapshot"; | ||
| 16 | import { | ||
| 17 | useRemoteConnectionStatus, | ||
| 18 | useRemoteEnvironmentState, | ||
| 19 | } from "./use-remote-environment-registry"; | ||
| 20 | |||
| 21 | const projectsSortOrder = Order.mapInput( | ||
| 22 | Order.Struct({ | ||
| 23 | title: Order.String, | ||
| 24 | environmentId: Order.String, | ||
| 25 | }), | ||
| 26 | (project: EnvironmentScopedProjectShell) => ({ | ||
| 27 | title: project.title, | ||
| 28 | environmentId: project.environmentId, | ||
| 29 | }), | ||
| 30 | ); | ||
| 31 | |||
| 32 | const threadsSortOrder = Order.mapInput( | ||
| 33 | Order.Struct({ | ||
| 34 | activityAt: Order.flip(Order.String), | ||
| 35 | environmentId: Order.String, | ||
| 36 | }), | ||
| 37 | (thread: EnvironmentScopedThreadShell) => ({ | ||
| 38 | activityAt: thread.updatedAt ?? thread.createdAt, | ||
| 39 | environmentId: thread.environmentId, | ||
| 40 | }), | ||
| 41 | ); | ||
| 42 | |||
| 43 | function deriveOverallConnectionState( | ||
| 44 | environments: ReadonlyArray<ConnectedEnvironmentSummary>, | ||
| 45 | ): EnvironmentConnectionState { | ||
| 46 | if (environments.length === 0) { | ||
| 47 | return "idle"; | ||
| 48 | } | ||
| 49 | if (environments.some((environment) => environment.connectionState === "ready")) { | ||
| 50 | return "ready"; | ||
| 51 | } | ||
| 52 | if (environments.some((environment) => environment.connectionState === "reconnecting")) { | ||
| 53 | return "reconnecting"; | ||
| 54 | } | ||
| 55 | if (environments.some((environment) => environment.connectionState === "connecting")) { | ||
| 56 | return "connecting"; | ||
| 57 | } | ||
| 58 | return "disconnected"; | ||
| 59 | } | ||
| 60 | |||
| 61 | function listRemoteCatalogEnvironmentIds( | ||
| 62 | savedConnectionsById: Readonly<Record<string, SavedRemoteConnection>>, | ||
| 63 | ): ReadonlyArray<SavedRemoteConnection["environmentId"]> { | ||
| 64 | const environmentIds: SavedRemoteConnection["environmentId"][] = []; | ||
| 65 | for (const connection of Object.values(savedConnectionsById)) { | ||
| 66 | environmentIds.push(connection.environmentId); | ||
| 67 | } | ||
| 68 | return environmentIds; | ||
| 69 | } | ||
| 70 | |||
| 71 | export interface RemoteCatalogState { | ||
| 72 | readonly isLoadingSavedConnections: boolean; | ||
| 73 | readonly hasSavedConnections: boolean; | ||
| 74 | readonly hasLoadedShellSnapshot: boolean; | ||
| 75 | readonly hasPendingShellSnapshot: boolean; | ||
| 76 | readonly hasReadyEnvironment: boolean; | ||
| 77 | readonly hasConnectingEnvironment: boolean; | ||
| 78 | readonly connectionState: EnvironmentConnectionState; | ||
| 79 | readonly connectionError: string | null; | ||
| 80 | readonly shellSnapshotError: string | null; | ||
| 81 | readonly isUsingCachedData: boolean; | ||
| 82 | readonly latestCachedSnapshotReceivedAt: string | null; | ||
| 83 | } | ||
| 84 | |||
| 85 | export function useRemoteCatalog() { | ||
| 86 | const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); | ||
| 87 | const { environmentStateById, isLoadingSavedConnection, savedConnectionsById } = | ||
| 88 | useRemoteEnvironmentState(); | ||
| 89 | const catalogEnvironmentIds = useMemo( | ||
| 90 | () => listRemoteCatalogEnvironmentIds(savedConnectionsById), | ||
| 91 | [savedConnectionsById], | ||
| 92 | ); | ||
| 93 | const shellSnapshotStates = useShellSnapshotStates(catalogEnvironmentIds); | ||
| 94 | const cachedShellSnapshotMetadata = useCachedShellSnapshotMetadata(); | ||
| 95 | |||
| 96 | const projects = useMemo(() => { | ||
| 97 | const scopedProjects: EnvironmentScopedProjectShell[] = []; | ||
| 98 | for (const connection of Object.values(savedConnectionsById)) { | ||
| 99 | const projects = shellSnapshotStates[connection.environmentId]?.data?.projects ?? []; | ||
| 100 | for (const project of projects) { | ||
| 101 | scopedProjects.push(scopeProjectShell(connection.environmentId, project)); | ||
| 102 | } | ||
| 103 | } | ||
| 104 | return Arr.sort(scopedProjects, projectsSortOrder); | ||
| 105 | }, [savedConnectionsById, shellSnapshotStates]); | ||
| 106 | |||
| 107 | const threads = useMemo(() => { | ||
| 108 | const scopedThreads: EnvironmentScopedThreadShell[] = []; | ||
| 109 | for (const connection of Object.values(savedConnectionsById)) { | ||
| 110 | const threads = shellSnapshotStates[connection.environmentId]?.data?.threads ?? []; | ||
| 111 | for (const thread of threads) { | ||
| 112 | scopedThreads.push(scopeThreadShell(connection.environmentId, thread)); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | return Arr.sort(scopedThreads, threadsSortOrder); | ||
| 116 | }, [savedConnectionsById, shellSnapshotStates]); | ||
| 117 | |||
| 118 | const serverConfigByEnvironmentId = useMemo( | ||
| 119 | () => | ||
| 120 | Object.fromEntries( | ||
| 121 | Object.entries(environmentStateById).map(([environmentId, runtime]) => [ | ||
| 122 | environmentId, | ||
| 123 | runtime.serverConfig ?? null, | ||
| 124 | ]), | ||
| 125 | ), | ||
| 126 | [environmentStateById], | ||
| 127 | ); | ||
| 128 | |||
| 129 | const overallConnectionState = useMemo( | ||
| 130 | () => deriveOverallConnectionState(connectedEnvironments), | ||
| 131 | [connectedEnvironments], | ||
| 132 | ); | ||
| 133 | |||
| 134 | const hasRemoteActivity = useMemo( | ||
| 135 | () => | ||
| 136 | threads.some( | ||
| 137 | (thread) => thread.session?.status === "running" || thread.session?.status === "starting", | ||
| 138 | ), | ||
| 139 | [threads], | ||
| 140 | ); | ||
| 141 | |||
| 142 | const state = useMemo<RemoteCatalogState>(() => { | ||
| 143 | const shellSnapshots = Object.values(shellSnapshotStates); | ||
| 144 | const cachedSnapshotReceivedAts: string[] = []; | ||
| 145 | for (const environmentId of catalogEnvironmentIds) { | ||
| 146 | const metadata = cachedShellSnapshotMetadata[environmentId]; | ||
| 147 | if (metadata) { | ||
| 148 | cachedSnapshotReceivedAts.push(metadata.snapshotReceivedAt); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | let shellSnapshotError: string | null = null; | ||
| 152 | for (const snapshot of shellSnapshots) { | ||
| 153 | if (snapshot.error !== null) { | ||
| 154 | shellSnapshotError = snapshot.error; | ||
| 155 | break; | ||
| 156 | } | ||
| 157 | } | ||
| 158 | return { | ||
| 159 | isLoadingSavedConnections: isLoadingSavedConnection, | ||
| 160 | hasSavedConnections: catalogEnvironmentIds.length > 0, | ||
| 161 | hasLoadedShellSnapshot: shellSnapshots.some((snapshot) => snapshot.data !== null), | ||
| 162 | hasPendingShellSnapshot: shellSnapshots.some((snapshot) => snapshot.isPending), | ||
| 163 | hasReadyEnvironment: connectedEnvironments.some( | ||
| 164 | (environment) => environment.connectionState === "ready", | ||
| 165 | ), | ||
| 166 | hasConnectingEnvironment: connectedEnvironments.some( | ||
| 167 | (environment) => | ||
| 168 | environment.connectionState === "connecting" || | ||
| 169 | environment.connectionState === "reconnecting", | ||
| 170 | ), | ||
| 171 | connectionState: connectionState ?? overallConnectionState, | ||
| 172 | connectionError, | ||
| 173 | shellSnapshotError, | ||
| 174 | isUsingCachedData: cachedSnapshotReceivedAts.length > 0, | ||
| 175 | latestCachedSnapshotReceivedAt: | ||
| 176 | Arr.sort(cachedSnapshotReceivedAts, Order.flip(Order.String))[0] ?? null, | ||
| 177 | }; | ||
| 178 | }, [ | ||
| 179 | cachedShellSnapshotMetadata, | ||
| 180 | catalogEnvironmentIds, | ||
| 181 | connectedEnvironments, | ||
| 182 | connectionError, | ||
| 183 | connectionState, | ||
| 184 | isLoadingSavedConnection, | ||
| 185 | overallConnectionState, | ||
| 186 | shellSnapshotStates, | ||
| 187 | ]); | ||
| 188 | |||
| 189 | return { | ||
| 190 | projects, | ||
| 191 | threads, | ||
| 192 | serverConfigByEnvironmentId, | ||
| 193 | connectionState: state.connectionState, | ||
| 194 | connectionError: state.connectionError, | ||
| 195 | state, | ||
| 196 | hasRemoteActivity, | ||
| 197 | }; | ||
| 198 | } | ||
apps/mobile/src/state/use-remote-environment-registry.ts created+618| ... | @@ -0,0 +1,618 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useCallback, useEffect, useMemo } from "react"; | ||
| 3 | import { Alert } from "react-native"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | type EnvironmentRuntimeState, | ||
| 7 | createEnvironmentConnection, | ||
| 8 | createEnvironmentConnectionAttemptRegistry, | ||
| 9 | createKnownEnvironment, | ||
| 10 | createWsRpcClient, | ||
| 11 | EnvironmentConnectionState, | ||
| 12 | WsTransport, | ||
| 13 | resolveRemoteWebSocketConnectionUrl, | ||
| 14 | } from "@t3tools/client-runtime"; | ||
| 15 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 16 | import * as Arr from "effect/Array"; | ||
| 17 | import * as Order from "effect/Order"; | ||
| 18 | import * as Option from "effect/Option"; | ||
| 19 | import { pipe } from "effect/Function"; | ||
| 20 | import { Atom } from "effect/unstable/reactivity"; | ||
| 21 | import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection"; | ||
| 22 | import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; | ||
| 23 | import { | ||
| 24 | clearCachedShellSnapshot, | ||
| 25 | clearSavedConnection, | ||
| 26 | loadCachedShellSnapshot, | ||
| 27 | loadSavedConnections, | ||
| 28 | saveCachedShellSnapshot, | ||
| 29 | saveConnection, | ||
| 30 | } from "../lib/storage"; | ||
| 31 | import { appAtomRegistry } from "./atom-registry"; | ||
| 32 | import { mobileRemoteHttpRuntime } from "../lib/runtime"; | ||
| 33 | import { | ||
| 34 | drainEnvironmentSessions, | ||
| 35 | notifyEnvironmentConnectionListeners, | ||
| 36 | removeEnvironmentSession, | ||
| 37 | setEnvironmentSession, | ||
| 38 | } from "./environment-session-registry"; | ||
| 39 | import { type ConnectedEnvironmentSummary } from "./remote-runtime-types"; | ||
| 40 | import { | ||
| 41 | invalidateSourceControlDiscoveryForEnvironment, | ||
| 42 | resetSourceControlDiscoveryState, | ||
| 43 | } from "./use-source-control-discovery"; | ||
| 44 | import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; | ||
| 45 | import { | ||
| 46 | clearCachedShellSnapshotMetadata, | ||
| 47 | hydrateCachedShellSnapshot, | ||
| 48 | markShellSnapshotLive, | ||
| 49 | shellSnapshotManager, | ||
| 50 | } from "./use-shell-snapshot"; | ||
| 51 | import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session"; | ||
| 52 | |||
| 53 | const terminalMetadataUnsubscribers = new Map<EnvironmentId, () => void>(); | ||
| 54 | const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); | ||
| 55 | const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000; | ||
| 56 | |||
| 57 | interface RemoteEnvironmentLocalState { | ||
| 58 | readonly isLoadingSavedConnection: boolean; | ||
| 59 | readonly connectionPairingUrl: string; | ||
| 60 | readonly pendingConnectionError: string | null; | ||
| 61 | readonly savedConnectionsById: Record<EnvironmentId, SavedRemoteConnection>; | ||
| 62 | } | ||
| 63 | |||
| 64 | const isLoadingSavedConnectionAtom = Atom.make(true).pipe( | ||
| 65 | Atom.keepAlive, | ||
| 66 | Atom.withLabel("mobile:is-loading-saved-connection"), | ||
| 67 | ); | ||
| 68 | |||
| 69 | const connectionPairingUrlAtom = Atom.make("").pipe( | ||
| 70 | Atom.keepAlive, | ||
| 71 | Atom.withLabel("mobile:connection-pairing-url"), | ||
| 72 | ); | ||
| 73 | |||
| 74 | const pendingConnectionErrorAtom = Atom.make<string | null>(null).pipe( | ||
| 75 | Atom.keepAlive, | ||
| 76 | Atom.withLabel("mobile:pending-connection-error"), | ||
| 77 | ); | ||
| 78 | |||
| 79 | const savedConnectionsByIdAtom = Atom.make<Record<EnvironmentId, SavedRemoteConnection>>({}).pipe( | ||
| 80 | Atom.keepAlive, | ||
| 81 | Atom.withLabel("mobile:saved-connections"), | ||
| 82 | ); | ||
| 83 | |||
| 84 | function getSavedConnectionsById(): Record<EnvironmentId, SavedRemoteConnection> { | ||
| 85 | return appAtomRegistry.get(savedConnectionsByIdAtom); | ||
| 86 | } | ||
| 87 | |||
| 88 | function setIsLoadingSavedConnection(value: boolean): void { | ||
| 89 | appAtomRegistry.set(isLoadingSavedConnectionAtom, value); | ||
| 90 | } | ||
| 91 | |||
| 92 | function setConnectionPairingUrl(pairingUrl: string): void { | ||
| 93 | appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl); | ||
| 94 | } | ||
| 95 | |||
| 96 | function clearConnectionPairingUrl(): void { | ||
| 97 | appAtomRegistry.set(connectionPairingUrlAtom, ""); | ||
| 98 | } | ||
| 99 | |||
| 100 | export function setPendingConnectionError(message: string | null): void { | ||
| 101 | appAtomRegistry.set(pendingConnectionErrorAtom, message); | ||
| 102 | } | ||
| 103 | |||
| 104 | function clearPendingConnectionError(): void { | ||
| 105 | appAtomRegistry.set(pendingConnectionErrorAtom, null); | ||
| 106 | } | ||
| 107 | |||
| 108 | function replaceSavedConnections(connections: Record<EnvironmentId, SavedRemoteConnection>): void { | ||
| 109 | appAtomRegistry.set(savedConnectionsByIdAtom, connections); | ||
| 110 | } | ||
| 111 | |||
| 112 | function upsertSavedConnection(connection: SavedRemoteConnection): void { | ||
| 113 | const current = appAtomRegistry.get(savedConnectionsByIdAtom); | ||
| 114 | appAtomRegistry.set(savedConnectionsByIdAtom, { | ||
| 115 | ...current, | ||
| 116 | [connection.environmentId]: connection, | ||
| 117 | }); | ||
| 118 | } | ||
| 119 | |||
| 120 | function removeSavedConnection(environmentId: EnvironmentId): void { | ||
| 121 | const current = appAtomRegistry.get(savedConnectionsByIdAtom); | ||
| 122 | const next = { ...current }; | ||
| 123 | delete next[environmentId]; | ||
| 124 | appAtomRegistry.set(savedConnectionsByIdAtom, next); | ||
| 125 | } | ||
| 126 | |||
| 127 | function useRemoteEnvironmentLocalState(): RemoteEnvironmentLocalState { | ||
| 128 | const isLoadingSavedConnection = useAtomValue(isLoadingSavedConnectionAtom); | ||
| 129 | const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); | ||
| 130 | const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); | ||
| 131 | const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom); | ||
| 132 | |||
| 133 | return useMemo( | ||
| 134 | () => ({ | ||
| 135 | isLoadingSavedConnection, | ||
| 136 | connectionPairingUrl, | ||
| 137 | pendingConnectionError, | ||
| 138 | savedConnectionsById, | ||
| 139 | }), | ||
| 140 | [connectionPairingUrl, isLoadingSavedConnection, pendingConnectionError, savedConnectionsById], | ||
| 141 | ); | ||
| 142 | } | ||
| 143 | |||
| 144 | function setEnvironmentConnectionStatus( | ||
| 145 | environmentId: EnvironmentId, | ||
| 146 | state: ConnectedEnvironmentSummary["connectionState"], | ||
| 147 | error?: string | null, | ||
| 148 | ) { | ||
| 149 | environmentRuntimeManager.patch({ environmentId }, (current) => ({ | ||
| 150 | ...current, | ||
| 151 | connectionState: state, | ||
| 152 | connectionError: error === undefined ? current.connectionError : error, | ||
| 153 | })); | ||
| 154 | } | ||
| 155 | |||
| 156 | function withTimeout<T>( | ||
| 157 | promise: Promise<T>, | ||
| 158 | timeoutMs: number, | ||
| 159 | timeoutMessage: string, | ||
| 160 | ): Promise<T> { | ||
| 161 | return new Promise<T>((resolve, reject) => { | ||
| 162 | const timeoutId = setTimeout(() => { | ||
| 163 | reject(new Error(timeoutMessage)); | ||
| 164 | }, timeoutMs); | ||
| 165 | |||
| 166 | promise.then( | ||
| 167 | (value) => { | ||
| 168 | clearTimeout(timeoutId); | ||
| 169 | resolve(value); | ||
| 170 | }, | ||
| 171 | (error: unknown) => { | ||
| 172 | clearTimeout(timeoutId); | ||
| 173 | reject(error); | ||
| 174 | }, | ||
| 175 | ); | ||
| 176 | }); | ||
| 177 | } | ||
| 178 | |||
| 179 | export async function disconnectEnvironment( | ||
| 180 | environmentId: EnvironmentId, | ||
| 181 | options?: { | ||
| 182 | readonly preserveShellSnapshot?: boolean; | ||
| 183 | readonly removeSaved?: boolean; | ||
| 184 | readonly preserveConnectionAttempt?: boolean; | ||
| 185 | }, | ||
| 186 | ) { | ||
| 187 | if (!options?.preserveConnectionAttempt) { | ||
| 188 | environmentConnectionAttempts.cancel(environmentId); | ||
| 189 | } | ||
| 190 | |||
| 191 | const session = removeEnvironmentSession(environmentId); | ||
| 192 | notifyEnvironmentConnectionListeners(); | ||
| 193 | await session?.connection.dispose(); | ||
| 194 | terminalMetadataUnsubscribers.get(environmentId)?.(); | ||
| 195 | terminalMetadataUnsubscribers.delete(environmentId); | ||
| 196 | if (!options?.preserveShellSnapshot) { | ||
| 197 | shellSnapshotManager.invalidate({ environmentId }); | ||
| 198 | } | ||
| 199 | invalidateSourceControlDiscoveryForEnvironment(environmentId); | ||
| 200 | terminalSessionManager.invalidateEnvironment(environmentId); | ||
| 201 | environmentRuntimeManager.invalidate({ environmentId }); | ||
| 202 | |||
| 203 | if (options?.removeSaved) { | ||
| 204 | await clearSavedConnection(environmentId); | ||
| 205 | await clearCachedShellSnapshot(environmentId); | ||
| 206 | clearCachedShellSnapshotMetadata(environmentId); | ||
| 207 | removeSavedConnection(environmentId); | ||
| 208 | } | ||
| 209 | } | ||
| 210 | |||
| 211 | export async function connectSavedEnvironment( | ||
| 212 | connection: SavedRemoteConnection, | ||
| 213 | options?: { readonly persist?: boolean }, | ||
| 214 | ) { | ||
| 215 | const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); | ||
| 216 | const isCurrentAttempt = connectionAttempt.isCurrent; | ||
| 217 | |||
| 218 | await disconnectEnvironment(connection.environmentId, { | ||
| 219 | preserveShellSnapshot: true, | ||
| 220 | preserveConnectionAttempt: true, | ||
| 221 | }); | ||
| 222 | if (!isCurrentAttempt()) { | ||
| 223 | return; | ||
| 224 | } | ||
| 225 | |||
| 226 | if (options?.persist !== false) { | ||
| 227 | await saveConnection(connection); | ||
| 228 | if (!isCurrentAttempt()) { | ||
| 229 | return; | ||
| 230 | } | ||
| 231 | } | ||
| 232 | |||
| 233 | upsertSavedConnection(connection); | ||
| 234 | setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); | ||
| 235 | shellSnapshotManager.markPending({ environmentId: connection.environmentId }); | ||
| 236 | |||
| 237 | const transport = new WsTransport( | ||
| 238 | () => | ||
| 239 | mobileRemoteHttpRuntime.runPromise( | ||
| 240 | resolveRemoteWebSocketConnectionUrl({ | ||
| 241 | wsBaseUrl: connection.wsBaseUrl, | ||
| 242 | httpBaseUrl: connection.httpBaseUrl, | ||
| 243 | bearerToken: connection.bearerToken, | ||
| 244 | }), | ||
| 245 | ), | ||
| 246 | { | ||
| 247 | onAttempt: () => { | ||
| 248 | if (!isCurrentAttempt()) { | ||
| 249 | return; | ||
| 250 | } | ||
| 251 | |||
| 252 | environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => { | ||
| 253 | const nextState = | ||
| 254 | previous.connectionState === "ready" || previous.connectionState === "reconnecting" | ||
| 255 | ? "reconnecting" | ||
| 256 | : "connecting"; | ||
| 257 | const keepSettledFailure = | ||
| 258 | previous.connectionState === "disconnected" && previous.connectionError !== null; | ||
| 259 | return { | ||
| 260 | ...previous, | ||
| 261 | connectionState: keepSettledFailure ? "disconnected" : nextState, | ||
| 262 | connectionError: keepSettledFailure ? previous.connectionError : null, | ||
| 263 | }; | ||
| 264 | }); | ||
| 265 | }, | ||
| 266 | onError: (message) => { | ||
| 267 | if (isCurrentAttempt()) { | ||
| 268 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); | ||
| 269 | } | ||
| 270 | }, | ||
| 271 | onClose: (details) => { | ||
| 272 | if (!isCurrentAttempt()) { | ||
| 273 | return; | ||
| 274 | } | ||
| 275 | |||
| 276 | const reason = | ||
| 277 | details.reason.trim().length > 0 | ||
| 278 | ? details.reason | ||
| 279 | : details.code === 1000 | ||
| 280 | ? null | ||
| 281 | : `Remote connection closed (${details.code}).`; | ||
| 282 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); | ||
| 283 | }, | ||
| 284 | }, | ||
| 285 | ); | ||
| 286 | |||
| 287 | const client = createWsRpcClient(transport); | ||
| 288 | const environmentConnection = createEnvironmentConnection({ | ||
| 289 | kind: "saved", | ||
| 290 | knownEnvironment: { | ||
| 291 | ...createKnownEnvironment({ | ||
| 292 | id: connection.environmentId, | ||
| 293 | label: connection.environmentLabel, | ||
| 294 | source: "manual", | ||
| 295 | target: { | ||
| 296 | httpBaseUrl: connection.httpBaseUrl, | ||
| 297 | wsBaseUrl: connection.wsBaseUrl, | ||
| 298 | }, | ||
| 299 | }), | ||
| 300 | environmentId: connection.environmentId, | ||
| 301 | }, | ||
| 302 | client, | ||
| 303 | applyShellEvent: (event, environmentId) => { | ||
| 304 | if (isCurrentAttempt()) { | ||
| 305 | shellSnapshotManager.applyEvent({ environmentId }, event); | ||
| 306 | } | ||
| 307 | }, | ||
| 308 | syncShellSnapshot: (snapshot, environmentId) => { | ||
| 309 | if (!isCurrentAttempt()) { | ||
| 310 | return; | ||
| 311 | } | ||
| 312 | |||
| 313 | shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); | ||
| 314 | markShellSnapshotLive(environmentId); | ||
| 315 | void saveCachedShellSnapshot(environmentId, snapshot); | ||
| 316 | environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ | ||
| 317 | ...runtime, | ||
| 318 | connectionState: "ready", | ||
| 319 | connectionError: null, | ||
| 320 | })); | ||
| 321 | }, | ||
| 322 | onShellResubscribe: (environmentId) => { | ||
| 323 | if (isCurrentAttempt()) { | ||
| 324 | shellSnapshotManager.markPending({ environmentId }); | ||
| 325 | } | ||
| 326 | }, | ||
| 327 | onConfigSnapshot: (serverConfig) => { | ||
| 328 | if (isCurrentAttempt()) { | ||
| 329 | environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (runtime) => ({ | ||
| 330 | ...runtime, | ||
| 331 | serverConfig, | ||
| 332 | })); | ||
| 333 | } | ||
| 334 | }, | ||
| 335 | }); | ||
| 336 | |||
| 337 | if (!isCurrentAttempt()) { | ||
| 338 | await environmentConnection.dispose(); | ||
| 339 | return; | ||
| 340 | } | ||
| 341 | |||
| 342 | setEnvironmentSession(connection.environmentId, { | ||
| 343 | client, | ||
| 344 | connection: environmentConnection, | ||
| 345 | }); | ||
| 346 | terminalMetadataUnsubscribers.set( | ||
| 347 | connection.environmentId, | ||
| 348 | subscribeTerminalMetadata({ | ||
| 349 | environmentId: connection.environmentId, | ||
| 350 | client, | ||
| 351 | }), | ||
| 352 | ); | ||
| 353 | terminalDebugLog("registry:terminal-metadata-subscribed", { | ||
| 354 | environmentId: connection.environmentId, | ||
| 355 | }); | ||
| 356 | notifyEnvironmentConnectionListeners(); | ||
| 357 | |||
| 358 | try { | ||
| 359 | await withTimeout( | ||
| 360 | environmentConnection.ensureBootstrapped(), | ||
| 361 | SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS, | ||
| 362 | "Environment did not respond before the connection timeout.", | ||
| 363 | ); | ||
| 364 | } catch (error) { | ||
| 365 | if (isCurrentAttempt()) { | ||
| 366 | setEnvironmentConnectionStatus( | ||
| 367 | connection.environmentId, | ||
| 368 | "disconnected", | ||
| 369 | error instanceof Error ? error.message : "Failed to bootstrap remote connection.", | ||
| 370 | ); | ||
| 371 | } | ||
| 372 | } | ||
| 373 | } | ||
| 374 | |||
| 375 | const environmentsSortOrder = Order.mapInput( | ||
| 376 | Order.Struct({ | ||
| 377 | environmentLabel: Order.String, | ||
| 378 | }), | ||
| 379 | (environment: ConnectedEnvironmentSummary) => ({ | ||
| 380 | environmentLabel: environment.environmentLabel, | ||
| 381 | }), | ||
| 382 | ); | ||
| 383 | |||
| 384 | function deriveConnectedEnvironments( | ||
| 385 | savedConnectionsById: Record<string, SavedRemoteConnection>, | ||
| 386 | environmentStateById: Record<EnvironmentId, EnvironmentRuntimeState>, | ||
| 387 | ): ReadonlyArray<ConnectedEnvironmentSummary> { | ||
| 388 | return Arr.sort( | ||
| 389 | Object.values(savedConnectionsById).map((connection) => { | ||
| 390 | const runtime = environmentStateById[connection.environmentId]; | ||
| 391 | return { | ||
| 392 | environmentId: connection.environmentId, | ||
| 393 | environmentLabel: connection.environmentLabel, | ||
| 394 | displayUrl: connection.displayUrl, | ||
| 395 | connectionState: runtime?.connectionState ?? "idle", | ||
| 396 | connectionError: runtime?.connectionError ?? null, | ||
| 397 | }; | ||
| 398 | }), | ||
| 399 | environmentsSortOrder, | ||
| 400 | ); | ||
| 401 | } | ||
| 402 | |||
| 403 | export function useRemoteEnvironmentBootstrap() { | ||
| 404 | useEffect(() => { | ||
| 405 | let cancelled = false; | ||
| 406 | |||
| 407 | void loadSavedConnections() | ||
| 408 | .then((connections) => { | ||
| 409 | if (cancelled) { | ||
| 410 | return; | ||
| 411 | } | ||
| 412 | |||
| 413 | replaceSavedConnections( | ||
| 414 | Object.fromEntries( | ||
| 415 | connections.map((connection) => [connection.environmentId, connection]), | ||
| 416 | ), | ||
| 417 | ); | ||
| 418 | |||
| 419 | setIsLoadingSavedConnection(false); | ||
| 420 | |||
| 421 | void (async () => { | ||
| 422 | await Promise.all( | ||
| 423 | connections.map(async (connection) => { | ||
| 424 | const cached = await loadCachedShellSnapshot(connection.environmentId); | ||
| 425 | if (!cancelled && cached) { | ||
| 426 | hydrateCachedShellSnapshot(cached); | ||
| 427 | } | ||
| 428 | }), | ||
| 429 | ); | ||
| 430 | |||
| 431 | if (cancelled) { | ||
| 432 | return; | ||
| 433 | } | ||
| 434 | |||
| 435 | await Promise.all( | ||
| 436 | connections.map((connection) => | ||
| 437 | connectSavedEnvironment(connection, { | ||
| 438 | persist: false, | ||
| 439 | }), | ||
| 440 | ), | ||
| 441 | ); | ||
| 442 | })(); | ||
| 443 | }) | ||
| 444 | .catch(() => { | ||
| 445 | if (!cancelled) { | ||
| 446 | setIsLoadingSavedConnection(false); | ||
| 447 | } | ||
| 448 | }); | ||
| 449 | |||
| 450 | return () => { | ||
| 451 | cancelled = true; | ||
| 452 | for (const session of drainEnvironmentSessions()) { | ||
| 453 | void session.connection.dispose(); | ||
| 454 | } | ||
| 455 | for (const unsubscribe of terminalMetadataUnsubscribers.values()) { | ||
| 456 | unsubscribe(); | ||
| 457 | } | ||
| 458 | terminalMetadataUnsubscribers.clear(); | ||
| 459 | environmentConnectionAttempts.clear(); | ||
| 460 | environmentRuntimeManager.invalidate(); | ||
| 461 | shellSnapshotManager.invalidate(); | ||
| 462 | resetSourceControlDiscoveryState(); | ||
| 463 | terminalSessionManager.invalidate(); | ||
| 464 | notifyEnvironmentConnectionListeners(); | ||
| 465 | }; | ||
| 466 | }, []); | ||
| 467 | } | ||
| 468 | |||
| 469 | export function useRemoteEnvironmentState() { | ||
| 470 | const state = useRemoteEnvironmentLocalState(); | ||
| 471 | const environmentStateById = useEnvironmentRuntimeStates( | ||
| 472 | Object.values(state.savedConnectionsById).map((connection) => connection.environmentId), | ||
| 473 | ); | ||
| 474 | |||
| 475 | return useMemo( | ||
| 476 | () => ({ | ||
| 477 | ...state, | ||
| 478 | environmentStateById, | ||
| 479 | }), | ||
| 480 | [environmentStateById, state], | ||
| 481 | ); | ||
| 482 | } | ||
| 483 | |||
| 484 | export function useRemoteConnectionStatus() { | ||
| 485 | const { environmentStateById, pendingConnectionError, savedConnectionsById } = | ||
| 486 | useRemoteEnvironmentState(); | ||
| 487 | |||
| 488 | const connectedEnvironments = useMemo( | ||
| 489 | () => deriveConnectedEnvironments(savedConnectionsById, environmentStateById), | ||
| 490 | [environmentStateById, savedConnectionsById], | ||
| 491 | ); | ||
| 492 | |||
| 493 | const connectionState = useMemo<EnvironmentConnectionState>(() => { | ||
| 494 | if (connectedEnvironments.length === 0) { | ||
| 495 | return "idle"; | ||
| 496 | } | ||
| 497 | if (connectedEnvironments.some((environment) => environment.connectionState === "ready")) { | ||
| 498 | return "ready"; | ||
| 499 | } | ||
| 500 | if ( | ||
| 501 | connectedEnvironments.some((environment) => environment.connectionState === "reconnecting") | ||
| 502 | ) { | ||
| 503 | return "reconnecting"; | ||
| 504 | } | ||
| 505 | if (connectedEnvironments.some((environment) => environment.connectionState === "connecting")) { | ||
| 506 | return "connecting"; | ||
| 507 | } | ||
| 508 | return "disconnected"; | ||
| 509 | }, [connectedEnvironments]); | ||
| 510 | |||
| 511 | const connectionError = useMemo( | ||
| 512 | () => | ||
| 513 | pipe( | ||
| 514 | Arr.appendAll( | ||
| 515 | [pendingConnectionError], | ||
| 516 | Arr.map(connectedEnvironments, (environment) => environment.connectionError), | ||
| 517 | ), | ||
| 518 | Arr.findFirst((value) => value !== null), | ||
| 519 | Option.getOrNull, | ||
| 520 | ), | ||
| 521 | [connectedEnvironments, pendingConnectionError], | ||
| 522 | ); | ||
| 523 | |||
| 524 | return { | ||
| 525 | connectedEnvironments, | ||
| 526 | connectionState, | ||
| 527 | connectionError, | ||
| 528 | }; | ||
| 529 | } | ||
| 530 | |||
| 531 | export function useRemoteConnections() { | ||
| 532 | const { connectionPairingUrl, pendingConnectionError } = useRemoteEnvironmentState(); | ||
| 533 | const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); | ||
| 534 | |||
| 535 | const onConnectPress = useCallback( | ||
| 536 | async (pairingUrl?: string) => { | ||
| 537 | try { | ||
| 538 | const nextPairingUrl = pairingUrl ?? connectionPairingUrl; | ||
| 539 | const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); | ||
| 540 | clearPendingConnectionError(); | ||
| 541 | await connectSavedEnvironment(connection); | ||
| 542 | clearConnectionPairingUrl(); | ||
| 543 | } catch (error) { | ||
| 544 | setPendingConnectionError( | ||
| 545 | error instanceof Error ? error.message : "Failed to pair with the environment.", | ||
| 546 | ); | ||
| 547 | throw error; | ||
| 548 | } | ||
| 549 | }, | ||
| 550 | [connectionPairingUrl], | ||
| 551 | ); | ||
| 552 | |||
| 553 | const onUpdateEnvironment = useCallback( | ||
| 554 | async ( | ||
| 555 | environmentId: EnvironmentId, | ||
| 556 | updates: { readonly label: string; readonly displayUrl: string }, | ||
| 557 | ) => { | ||
| 558 | const connection = getSavedConnectionsById()[environmentId]; | ||
| 559 | if (!connection) { | ||
| 560 | return; | ||
| 561 | } | ||
| 562 | |||
| 563 | const updated: SavedRemoteConnection = { | ||
| 564 | ...connection, | ||
| 565 | environmentLabel: updates.label.trim() || connection.environmentLabel, | ||
| 566 | displayUrl: updates.displayUrl.trim() || connection.displayUrl, | ||
| 567 | }; | ||
| 568 | |||
| 569 | await saveConnection(updated); | ||
| 570 | upsertSavedConnection(updated); | ||
| 571 | }, | ||
| 572 | [], | ||
| 573 | ); | ||
| 574 | |||
| 575 | const onReconnectEnvironment = useCallback((environmentId: EnvironmentId) => { | ||
| 576 | const connection = getSavedConnectionsById()[environmentId]; | ||
| 577 | if (!connection) { | ||
| 578 | return; | ||
| 579 | } | ||
| 580 | void connectSavedEnvironment(connection, { persist: false }); | ||
| 581 | }, []); | ||
| 582 | |||
| 583 | const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { | ||
| 584 | const connection = getSavedConnectionsById()[environmentId]; | ||
| 585 | if (!connection) { | ||
| 586 | return; | ||
| 587 | } | ||
| 588 | |||
| 589 | Alert.alert( | ||
| 590 | "Remove environment?", | ||
| 591 | `Disconnect and forget ${connection.environmentLabel} on this device.`, | ||
| 592 | [ | ||
| 593 | { text: "Cancel", style: "cancel" }, | ||
| 594 | { | ||
| 595 | text: "Remove", | ||
| 596 | style: "destructive", | ||
| 597 | onPress: () => { | ||
| 598 | void disconnectEnvironment(environmentId, { removeSaved: true }); | ||
| 599 | }, | ||
| 600 | }, | ||
| 601 | ], | ||
| 602 | ); | ||
| 603 | }, []); | ||
| 604 | |||
| 605 | return { | ||
| 606 | connectionPairingUrl, | ||
| 607 | connectionState, | ||
| 608 | connectionError, | ||
| 609 | pairingConnectionError: pendingConnectionError, | ||
| 610 | connectedEnvironments, | ||
| 611 | connectedEnvironmentCount: connectedEnvironments.length, | ||
| 612 | onChangeConnectionPairingUrl: setConnectionPairingUrl, | ||
| 613 | onConnectPress, | ||
| 614 | onReconnectEnvironment, | ||
| 615 | onUpdateEnvironment, | ||
| 616 | onRemoveEnvironmentPress, | ||
| 617 | }; | ||
| 618 | } | ||
apps/mobile/src/state/use-selected-thread-commands.ts created+187| ... | @@ -0,0 +1,187 @@ | ||
| 1 | import { useCallback } from "react"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CommandId, | ||
| 5 | type ModelSelection, | ||
| 6 | type ProviderInteractionMode, | ||
| 7 | type RuntimeMode, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | |||
| 10 | import { uuidv4 } from "../lib/uuid"; | ||
| 11 | import { environmentRuntimeManager } from "./use-environment-runtime"; | ||
| 12 | import { getEnvironmentClient } from "./environment-session-registry"; | ||
| 13 | import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; | ||
| 14 | import { useThreadSelection } from "./use-thread-selection"; | ||
| 15 | |||
| 16 | export function useSelectedThreadCommands(input: { | ||
| 17 | readonly refreshSelectedThreadGitStatus: (options?: { | ||
| 18 | readonly quiet?: boolean; | ||
| 19 | readonly cwd?: string | null; | ||
| 20 | }) => Promise<unknown>; | ||
| 21 | }) { | ||
| 22 | const { refreshSelectedThreadGitStatus } = input; | ||
| 23 | const { selectedThread } = useThreadSelection(); | ||
| 24 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 25 | |||
| 26 | const onRefresh = useCallback(async () => { | ||
| 27 | const targets = selectedThread | ||
| 28 | ? [selectedThread.environmentId] | ||
| 29 | : Object.values(savedConnectionsById).map((connection) => connection.environmentId); | ||
| 30 | |||
| 31 | await Promise.all( | ||
| 32 | targets.map(async (environmentId) => { | ||
| 33 | const client = getEnvironmentClient(environmentId); | ||
| 34 | if (!client) { | ||
| 35 | return; | ||
| 36 | } | ||
| 37 | |||
| 38 | try { | ||
| 39 | const serverConfig = await client.server.getConfig(); | ||
| 40 | environmentRuntimeManager.patch({ environmentId }, (current) => ({ | ||
| 41 | ...current, | ||
| 42 | serverConfig, | ||
| 43 | connectionError: null, | ||
| 44 | })); | ||
| 45 | } catch (error) { | ||
| 46 | environmentRuntimeManager.patch({ environmentId }, (current) => ({ | ||
| 47 | ...current, | ||
| 48 | connectionError: | ||
| 49 | error instanceof Error ? error.message : "Failed to refresh remote data.", | ||
| 50 | })); | ||
| 51 | } | ||
| 52 | }), | ||
| 53 | ); | ||
| 54 | |||
| 55 | if (selectedThread) { | ||
| 56 | await refreshSelectedThreadGitStatus({ quiet: true }); | ||
| 57 | } | ||
| 58 | }, [refreshSelectedThreadGitStatus, savedConnectionsById, selectedThread]); | ||
| 59 | |||
| 60 | const onUpdateThreadModelSelection = useCallback( | ||
| 61 | async (modelSelection: ModelSelection) => { | ||
| 62 | if (!selectedThread) { | ||
| 63 | return; | ||
| 64 | } | ||
| 65 | |||
| 66 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 67 | if (!client) { | ||
| 68 | return; | ||
| 69 | } | ||
| 70 | |||
| 71 | await client.orchestration.dispatchCommand({ | ||
| 72 | type: "thread.meta.update", | ||
| 73 | commandId: CommandId.make(uuidv4()), | ||
| 74 | threadId: selectedThread.id, | ||
| 75 | modelSelection, | ||
| 76 | }); | ||
| 77 | }, | ||
| 78 | [selectedThread], | ||
| 79 | ); | ||
| 80 | |||
| 81 | const onUpdateThreadRuntimeMode = useCallback( | ||
| 82 | async (runtimeMode: RuntimeMode) => { | ||
| 83 | if (!selectedThread) { | ||
| 84 | return; | ||
| 85 | } | ||
| 86 | |||
| 87 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 88 | if (!client) { | ||
| 89 | return; | ||
| 90 | } | ||
| 91 | |||
| 92 | await client.orchestration.dispatchCommand({ | ||
| 93 | type: "thread.runtime-mode.set", | ||
| 94 | commandId: CommandId.make(uuidv4()), | ||
| 95 | threadId: selectedThread.id, | ||
| 96 | runtimeMode, | ||
| 97 | createdAt: new Date().toISOString(), | ||
| 98 | }); | ||
| 99 | }, | ||
| 100 | [selectedThread], | ||
| 101 | ); | ||
| 102 | |||
| 103 | const onUpdateThreadInteractionMode = useCallback( | ||
| 104 | async (interactionMode: ProviderInteractionMode) => { | ||
| 105 | if (!selectedThread) { | ||
| 106 | return; | ||
| 107 | } | ||
| 108 | |||
| 109 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 110 | if (!client) { | ||
| 111 | return; | ||
| 112 | } | ||
| 113 | |||
| 114 | await client.orchestration.dispatchCommand({ | ||
| 115 | type: "thread.interaction-mode.set", | ||
| 116 | commandId: CommandId.make(uuidv4()), | ||
| 117 | threadId: selectedThread.id, | ||
| 118 | interactionMode, | ||
| 119 | createdAt: new Date().toISOString(), | ||
| 120 | }); | ||
| 121 | }, | ||
| 122 | [selectedThread], | ||
| 123 | ); | ||
| 124 | |||
| 125 | const onStopThread = useCallback(async () => { | ||
| 126 | if (!selectedThread) { | ||
| 127 | return; | ||
| 128 | } | ||
| 129 | |||
| 130 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 131 | if (!client) { | ||
| 132 | return; | ||
| 133 | } | ||
| 134 | |||
| 135 | if ( | ||
| 136 | selectedThread.session?.status !== "running" && | ||
| 137 | selectedThread.session?.status !== "starting" | ||
| 138 | ) { | ||
| 139 | return; | ||
| 140 | } | ||
| 141 | |||
| 142 | await client.orchestration.dispatchCommand({ | ||
| 143 | type: "thread.turn.interrupt", | ||
| 144 | commandId: CommandId.make(uuidv4()), | ||
| 145 | threadId: selectedThread.id, | ||
| 146 | ...(selectedThread.session?.activeTurnId | ||
| 147 | ? { turnId: selectedThread.session.activeTurnId } | ||
| 148 | : {}), | ||
| 149 | createdAt: new Date().toISOString(), | ||
| 150 | }); | ||
| 151 | }, [selectedThread]); | ||
| 152 | |||
| 153 | const onRenameThread = useCallback( | ||
| 154 | async (title: string) => { | ||
| 155 | if (!selectedThread) { | ||
| 156 | return; | ||
| 157 | } | ||
| 158 | |||
| 159 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 160 | if (!client) { | ||
| 161 | return; | ||
| 162 | } | ||
| 163 | |||
| 164 | const trimmed = title.trim(); | ||
| 165 | if (!trimmed || trimmed === selectedThread.title) { | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | |||
| 169 | await client.orchestration.dispatchCommand({ | ||
| 170 | type: "thread.meta.update", | ||
| 171 | commandId: CommandId.make(uuidv4()), | ||
| 172 | threadId: selectedThread.id, | ||
| 173 | title: trimmed, | ||
| 174 | }); | ||
| 175 | }, | ||
| 176 | [selectedThread], | ||
| 177 | ); | ||
| 178 | |||
| 179 | return { | ||
| 180 | onRefresh, | ||
| 181 | onUpdateThreadModelSelection, | ||
| 182 | onUpdateThreadRuntimeMode, | ||
| 183 | onUpdateThreadInteractionMode, | ||
| 184 | onRenameThread, | ||
| 185 | onStopThread, | ||
| 186 | }; | ||
| 187 | } | ||
apps/mobile/src/state/use-selected-thread-git-actions.ts created+341| ... | @@ -0,0 +1,341 @@ | ||
| 1 | import { useCallback, useEffect } from "react"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | EnvironmentScopedProjectShell, | ||
| 5 | EnvironmentScopedThreadShell, | ||
| 6 | type VcsRef, | ||
| 7 | type GitActionRequestInput, | ||
| 8 | } from "@t3tools/client-runtime"; | ||
| 9 | import { CommandId, type GitRunStackedActionResult } from "@t3tools/contracts"; | ||
| 10 | import { | ||
| 11 | dedupeRemoteBranchesWithLocalMatches, | ||
| 12 | sanitizeFeatureBranchName, | ||
| 13 | } from "@t3tools/shared/git"; | ||
| 14 | |||
| 15 | import { uuidv4 } from "../lib/uuid"; | ||
| 16 | import { getEnvironmentClient } from "./environment-session-registry"; | ||
| 17 | import { setPendingConnectionError } from "./use-remote-environment-registry"; | ||
| 18 | import { vcsActionManager, showGitActionResult } from "./use-vcs-action-state"; | ||
| 19 | import { vcsRefManager } from "./use-vcs-refs"; | ||
| 20 | import { vcsStatusManager } from "./use-vcs-status"; | ||
| 21 | import { useThreadSelection } from "./use-thread-selection"; | ||
| 22 | import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; | ||
| 23 | |||
| 24 | export function useSelectedThreadGitActions() { | ||
| 25 | const { selectedThread, selectedThreadProject } = useThreadSelection(); | ||
| 26 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); | ||
| 27 | |||
| 28 | const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null; | ||
| 29 | |||
| 30 | const updateThreadGitContext = useCallback( | ||
| 31 | async ( | ||
| 32 | thread: NonNullable<typeof selectedThread>, | ||
| 33 | nextState: { | ||
| 34 | readonly branch?: string | null; | ||
| 35 | readonly worktreePath?: string | null; | ||
| 36 | }, | ||
| 37 | ) => { | ||
| 38 | const client = getEnvironmentClient(thread.environmentId); | ||
| 39 | if (!client) { | ||
| 40 | return; | ||
| 41 | } | ||
| 42 | |||
| 43 | await client.orchestration.dispatchCommand({ | ||
| 44 | type: "thread.meta.update", | ||
| 45 | commandId: CommandId.make(uuidv4()), | ||
| 46 | threadId: thread.id, | ||
| 47 | ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), | ||
| 48 | ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), | ||
| 49 | }); | ||
| 50 | }, | ||
| 51 | [], | ||
| 52 | ); | ||
| 53 | |||
| 54 | const refreshSelectedThreadGitStatus = useCallback( | ||
| 55 | async (options?: { readonly quiet?: boolean; readonly cwd?: string | null }) => { | ||
| 56 | if (!selectedThread || !selectedThreadProject) { | ||
| 57 | return null; | ||
| 58 | } | ||
| 59 | |||
| 60 | const cwd = options?.cwd ?? selectedThreadCwd; | ||
| 61 | if (!cwd) { | ||
| 62 | return null; | ||
| 63 | } | ||
| 64 | |||
| 65 | try { | ||
| 66 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 67 | if (!client) { | ||
| 68 | return null; | ||
| 69 | } | ||
| 70 | |||
| 71 | const status = await vcsActionManager.refreshStatus( | ||
| 72 | { environmentId: selectedThread.environmentId, cwd }, | ||
| 73 | { ...client.vcs, runChangeRequest: client.git.runStackedAction }, | ||
| 74 | options, | ||
| 75 | ); | ||
| 76 | setPendingConnectionError(null); | ||
| 77 | return status; | ||
| 78 | } catch (error) { | ||
| 79 | const message = error instanceof Error ? error.message : "Failed to refresh git status."; | ||
| 80 | setPendingConnectionError(message); | ||
| 81 | return null; | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | [selectedThread, selectedThreadCwd, selectedThreadProject], | ||
| 85 | ); | ||
| 86 | |||
| 87 | useEffect(() => { | ||
| 88 | if (!selectedThread || !selectedThreadProject) { | ||
| 89 | return; | ||
| 90 | } | ||
| 91 | |||
| 92 | void refreshSelectedThreadGitStatus({ quiet: true }); | ||
| 93 | }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]); | ||
| 94 | |||
| 95 | const runSelectedThreadGitMutation = useCallback( | ||
| 96 | async <T>( | ||
| 97 | operation: (input: { | ||
| 98 | readonly thread: EnvironmentScopedThreadShell; | ||
| 99 | readonly project: EnvironmentScopedProjectShell; | ||
| 100 | readonly cwd: string; | ||
| 101 | }) => Promise<T>, | ||
| 102 | ): Promise<T | null> => { | ||
| 103 | if (!selectedThread || !selectedThreadProject) { | ||
| 104 | return null; | ||
| 105 | } | ||
| 106 | |||
| 107 | const cwd = selectedThreadCwd; | ||
| 108 | if (!cwd) { | ||
| 109 | return null; | ||
| 110 | } | ||
| 111 | |||
| 112 | try { | ||
| 113 | setPendingConnectionError(null); | ||
| 114 | return await operation({ | ||
| 115 | thread: selectedThread, | ||
| 116 | project: selectedThreadProject, | ||
| 117 | cwd, | ||
| 118 | }); | ||
| 119 | } catch (error) { | ||
| 120 | const message = error instanceof Error ? error.message : "Git action failed."; | ||
| 121 | setPendingConnectionError(message); | ||
| 122 | showGitActionResult({ type: "error", title: "Git action failed", description: message }); | ||
| 123 | return null; | ||
| 124 | } | ||
| 125 | }, | ||
| 126 | [selectedThread, selectedThreadCwd, selectedThreadProject], | ||
| 127 | ); | ||
| 128 | |||
| 129 | const refreshSelectedThreadBranches = useCallback(async (): Promise<ReadonlyArray<VcsRef>> => { | ||
| 130 | if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) { | ||
| 131 | return []; | ||
| 132 | } | ||
| 133 | |||
| 134 | const client = getEnvironmentClient(selectedThread.environmentId); | ||
| 135 | if (!client) { | ||
| 136 | return []; | ||
| 137 | } | ||
| 138 | |||
| 139 | try { | ||
| 140 | const result = await vcsRefManager.load( | ||
| 141 | { environmentId: selectedThread.environmentId, cwd: selectedThreadGitRootCwd, query: null }, | ||
| 142 | client.vcs, | ||
| 143 | { limit: 100 }, | ||
| 144 | ); | ||
| 145 | return dedupeRemoteBranchesWithLocalMatches(result?.refs ?? []).filter( | ||
| 146 | (branch) => !branch.isRemote, | ||
| 147 | ); | ||
| 148 | } catch (error) { | ||
| 149 | setPendingConnectionError( | ||
| 150 | error instanceof Error ? error.message : "Failed to load branches.", | ||
| 151 | ); | ||
| 152 | return []; | ||
| 153 | } | ||
| 154 | }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject]); | ||
| 155 | |||
| 156 | const syncSelectedThreadBranchState = useCallback( | ||
| 157 | async (input: { | ||
| 158 | readonly thread: EnvironmentScopedThreadShell; | ||
| 159 | readonly cwd: string; | ||
| 160 | readonly branchRootCwd?: string | null; | ||
| 161 | readonly nextThreadState?: { | ||
| 162 | readonly branch?: string | null; | ||
| 163 | readonly worktreePath?: string | null; | ||
| 164 | }; | ||
| 165 | }) => { | ||
| 166 | if (input.nextThreadState) { | ||
| 167 | await updateThreadGitContext(input.thread, input.nextThreadState); | ||
| 168 | } | ||
| 169 | |||
| 170 | const branchRootCwd = input.branchRootCwd ?? selectedThreadProject?.workspaceRoot ?? null; | ||
| 171 | if (branchRootCwd) { | ||
| 172 | vcsRefManager.invalidate({ | ||
| 173 | environmentId: input.thread.environmentId, | ||
| 174 | cwd: branchRootCwd, | ||
| 175 | query: null, | ||
| 176 | }); | ||
| 177 | await refreshSelectedThreadBranches(); | ||
| 178 | } | ||
| 179 | |||
| 180 | await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); | ||
| 181 | }, | ||
| 182 | [ | ||
| 183 | refreshSelectedThreadBranches, | ||
| 184 | refreshSelectedThreadGitStatus, | ||
| 185 | selectedThreadProject?.workspaceRoot, | ||
| 186 | updateThreadGitContext, | ||
| 187 | ], | ||
| 188 | ); | ||
| 189 | |||
| 190 | const onCheckoutSelectedThreadBranch = useCallback( | ||
| 191 | async (branch: string) => { | ||
| 192 | await runSelectedThreadGitMutation(async ({ thread, cwd }) => { | ||
| 193 | const result = await vcsActionManager.switchRef( | ||
| 194 | { environmentId: thread.environmentId, cwd }, | ||
| 195 | { refName: branch }, | ||
| 196 | ); | ||
| 197 | await syncSelectedThreadBranchState({ | ||
| 198 | thread, | ||
| 199 | cwd, | ||
| 200 | nextThreadState: { | ||
| 201 | branch: result?.refName ?? thread.branch, | ||
| 202 | worktreePath: selectedThreadWorktreePath, | ||
| 203 | }, | ||
| 204 | }); | ||
| 205 | }); | ||
| 206 | }, | ||
| 207 | [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], | ||
| 208 | ); | ||
| 209 | |||
| 210 | const onCreateSelectedThreadBranch = useCallback( | ||
| 211 | async (branch: string) => { | ||
| 212 | await runSelectedThreadGitMutation(async ({ thread, cwd }) => { | ||
| 213 | const result = await vcsActionManager.createRef( | ||
| 214 | { environmentId: thread.environmentId, cwd }, | ||
| 215 | { | ||
| 216 | refName: branch, | ||
| 217 | switchRef: true, | ||
| 218 | }, | ||
| 219 | ); | ||
| 220 | await syncSelectedThreadBranchState({ | ||
| 221 | thread, | ||
| 222 | cwd, | ||
| 223 | nextThreadState: { | ||
| 224 | branch: result?.refName ?? thread.branch, | ||
| 225 | worktreePath: selectedThreadWorktreePath, | ||
| 226 | }, | ||
| 227 | }); | ||
| 228 | }); | ||
| 229 | }, | ||
| 230 | [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], | ||
| 231 | ); | ||
| 232 | |||
| 233 | const onCreateSelectedThreadWorktree = useCallback( | ||
| 234 | async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { | ||
| 235 | await runSelectedThreadGitMutation(async ({ thread, project }) => { | ||
| 236 | const result = await vcsActionManager.createWorktree( | ||
| 237 | { environmentId: thread.environmentId, cwd: project.workspaceRoot }, | ||
| 238 | { | ||
| 239 | refName: nextWorktree.baseBranch, | ||
| 240 | newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), | ||
| 241 | path: null, | ||
| 242 | }, | ||
| 243 | ); | ||
| 244 | if (!result) { | ||
| 245 | return; | ||
| 246 | } | ||
| 247 | |||
| 248 | await syncSelectedThreadBranchState({ | ||
| 249 | thread, | ||
| 250 | cwd: result.worktree.path, | ||
| 251 | branchRootCwd: project.workspaceRoot, | ||
| 252 | nextThreadState: { | ||
| 253 | branch: result.worktree.refName, | ||
| 254 | worktreePath: result.worktree.path, | ||
| 255 | }, | ||
| 256 | }); | ||
| 257 | }); | ||
| 258 | }, | ||
| 259 | [runSelectedThreadGitMutation, syncSelectedThreadBranchState], | ||
| 260 | ); | ||
| 261 | |||
| 262 | const onPullSelectedThreadBranch = useCallback(async () => { | ||
| 263 | await runSelectedThreadGitMutation(async ({ thread, cwd }) => { | ||
| 264 | const result = await vcsActionManager.pull({ environmentId: thread.environmentId, cwd }); | ||
| 265 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | ||
| 266 | if (result) { | ||
| 267 | showGitActionResult({ | ||
| 268 | type: "success", | ||
| 269 | title: | ||
| 270 | result.status === "skipped_up_to_date" | ||
| 271 | ? "Already up to date" | ||
| 272 | : `Pulled latest on ${result.refName}`, | ||
| 273 | }); | ||
| 274 | } | ||
| 275 | }); | ||
| 276 | }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); | ||
| 277 | |||
| 278 | const onRunSelectedThreadGitAction = useCallback( | ||
| 279 | async (input: GitActionRequestInput): Promise<GitRunStackedActionResult | null> => { | ||
| 280 | return await runSelectedThreadGitMutation(async ({ thread, cwd }) => { | ||
| 281 | const result = await vcsActionManager.runChangeRequest( | ||
| 282 | { environmentId: thread.environmentId, cwd }, | ||
| 283 | { | ||
| 284 | actionId: uuidv4(), | ||
| 285 | action: input.action, | ||
| 286 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), | ||
| 287 | ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), | ||
| 288 | ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), | ||
| 289 | }, | ||
| 290 | { | ||
| 291 | gitStatus: vcsStatusManager.getSnapshot({ | ||
| 292 | environmentId: thread.environmentId, | ||
| 293 | cwd, | ||
| 294 | }).data, | ||
| 295 | }, | ||
| 296 | ); | ||
| 297 | if (!result) { | ||
| 298 | return null; | ||
| 299 | } | ||
| 300 | |||
| 301 | showGitActionResult({ | ||
| 302 | type: "success", | ||
| 303 | title: result.toast.title, | ||
| 304 | description: result.toast.description, | ||
| 305 | prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined, | ||
| 306 | }); | ||
| 307 | |||
| 308 | if (result.branch.status === "created" && result.branch.name) { | ||
| 309 | await syncSelectedThreadBranchState({ | ||
| 310 | thread, | ||
| 311 | cwd, | ||
| 312 | nextThreadState: { | ||
| 313 | branch: result.branch.name, | ||
| 314 | worktreePath: selectedThreadWorktreePath, | ||
| 315 | }, | ||
| 316 | }); | ||
| 317 | return result; | ||
| 318 | } | ||
| 319 | |||
| 320 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | ||
| 321 | return result; | ||
| 322 | }); | ||
| 323 | }, | ||
| 324 | [ | ||
| 325 | refreshSelectedThreadGitStatus, | ||
| 326 | runSelectedThreadGitMutation, | ||
| 327 | selectedThreadWorktreePath, | ||
| 328 | syncSelectedThreadBranchState, | ||
| 329 | ], | ||
| 330 | ); | ||
| 331 | |||
| 332 | return { | ||
| 333 | refreshSelectedThreadGitStatus, | ||
| 334 | refreshSelectedThreadBranches, | ||
| 335 | onCheckoutSelectedThreadBranch, | ||
| 336 | onCreateSelectedThreadBranch, | ||
| 337 | onCreateSelectedThreadWorktree, | ||
| 338 | onPullSelectedThreadBranch, | ||
| 339 | onRunSelectedThreadGitAction, | ||
| 340 | }; | ||
| 341 | } | ||
apps/mobile/src/state/use-selected-thread-git-state.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | |||
| 3 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; | ||
| 4 | |||
| 5 | import { useVcsActionState } from "./use-vcs-action-state"; | ||
| 6 | import { useVcsRefs } from "./use-vcs-refs"; | ||
| 7 | import { useSourceControlDiscovery } from "./use-source-control-discovery"; | ||
| 8 | import { useThreadSelection } from "./use-thread-selection"; | ||
| 9 | import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; | ||
| 10 | |||
| 11 | export function useSelectedThreadGitState() { | ||
| 12 | const { selectedThread, selectedThreadProject } = useThreadSelection(); | ||
| 13 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | ||
| 14 | |||
| 15 | const selectedThreadGitTarget = useMemo( | ||
| 16 | () => ({ | ||
| 17 | environmentId: selectedThread?.environmentId ?? null, | ||
| 18 | cwd: selectedThreadCwd, | ||
| 19 | }), | ||
| 20 | [selectedThread?.environmentId, selectedThreadCwd], | ||
| 21 | ); | ||
| 22 | const gitActionState = useVcsActionState(selectedThreadGitTarget); | ||
| 23 | const sourceControlDiscovery = useSourceControlDiscovery(selectedThread?.environmentId ?? null); | ||
| 24 | |||
| 25 | const selectedThreadBranchTarget = useMemo( | ||
| 26 | () => ({ | ||
| 27 | environmentId: selectedThread?.environmentId ?? null, | ||
| 28 | cwd: selectedThreadProject?.workspaceRoot ?? null, | ||
| 29 | query: null, | ||
| 30 | }), | ||
| 31 | [selectedThread?.environmentId, selectedThreadProject?.workspaceRoot], | ||
| 32 | ); | ||
| 33 | const selectedThreadBranchState = useVcsRefs(selectedThreadBranchTarget); | ||
| 34 | const selectedThreadBranches = useMemo( | ||
| 35 | () => | ||
| 36 | dedupeRemoteBranchesWithLocalMatches(selectedThreadBranchState.data?.refs ?? []).filter( | ||
| 37 | (branch) => !branch.isRemote, | ||
| 38 | ), | ||
| 39 | [selectedThreadBranchState.data?.refs], | ||
| 40 | ); | ||
| 41 | |||
| 42 | return { | ||
| 43 | gitOperationLabel: gitActionState.currentLabel, | ||
| 44 | sourceControlDiscovery, | ||
| 45 | selectedThreadBranches, | ||
| 46 | selectedThreadBranchesLoading: selectedThreadBranchState.isPending, | ||
| 47 | }; | ||
| 48 | } | ||
apps/mobile/src/state/use-selected-thread-requests.ts created+176| ... | @@ -0,0 +1,176 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useCallback, useMemo, useState } from "react"; | ||
| 3 | |||
| 4 | import { ApprovalRequestId, CommandId, type ProviderApprovalDecision } from "@t3tools/contracts"; | ||
| 5 | import { Atom } from "effect/unstable/reactivity"; | ||
| 6 | |||
| 7 | import { scopedRequestKey } from "../lib/scopedEntities"; | ||
| 8 | import { | ||
| 9 | buildPendingUserInputAnswers, | ||
| 10 | derivePendingApprovals, | ||
| 11 | derivePendingUserInputs, | ||
| 12 | setPendingUserInputCustomAnswer, | ||
| 13 | type PendingUserInputDraftAnswer, | ||
| 14 | } from "../lib/threadActivity"; | ||
| 15 | import { uuidv4 } from "../lib/uuid"; | ||
| 16 | import { appAtomRegistry } from "./atom-registry"; | ||
| 17 | import { getEnvironmentClient } from "./environment-session-registry"; | ||
| 18 | import { useSelectedThreadDetail } from "./use-thread-detail"; | ||
| 19 | import { useThreadSelection } from "./use-thread-selection"; | ||
| 20 | |||
| 21 | const userInputDraftsByRequestKeyAtom = Atom.make< | ||
| 22 | Record<string, Record<string, PendingUserInputDraftAnswer>> | ||
| 23 | >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); | ||
| 24 | |||
| 25 | function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void { | ||
| 26 | const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); | ||
| 27 | appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { | ||
| 28 | ...current, | ||
| 29 | [requestKey]: { | ||
| 30 | ...current[requestKey], | ||
| 31 | [questionId]: { | ||
| 32 | selectedOptionLabel: label, | ||
| 33 | }, | ||
| 34 | }, | ||
| 35 | }); | ||
| 36 | } | ||
| 37 | |||
| 38 | function setUserInputDraftCustomAnswer( | ||
| 39 | requestKey: string, | ||
| 40 | questionId: string, | ||
| 41 | customAnswer: string, | ||
| 42 | ): void { | ||
| 43 | const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); | ||
| 44 | appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { | ||
| 45 | ...current, | ||
| 46 | [requestKey]: { | ||
| 47 | ...current[requestKey], | ||
| 48 | [questionId]: setPendingUserInputCustomAnswer( | ||
| 49 | current[requestKey]?.[questionId], | ||
| 50 | customAnswer, | ||
| 51 | ), | ||
| 52 | }, | ||
| 53 | }); | ||
| 54 | } | ||
| 55 | |||
| 56 | export function useSelectedThreadRequests() { | ||
| 57 | const { selectedThread: selectedThreadShell } = useThreadSelection(); | ||
| 58 | const selectedThread = useSelectedThreadDetail(); | ||
| 59 | const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); | ||
| 60 | const [respondingApprovalId, setRespondingApprovalId] = useState<ApprovalRequestId | null>(null); | ||
| 61 | const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>( | ||
| 62 | null, | ||
| 63 | ); | ||
| 64 | |||
| 65 | const activePendingApprovals = useMemo( | ||
| 66 | () => (selectedThread ? derivePendingApprovals(selectedThread.activities) : []), | ||
| 67 | [selectedThread], | ||
| 68 | ); | ||
| 69 | const activePendingApproval = activePendingApprovals[0] ?? null; | ||
| 70 | const activePendingUserInputs = useMemo( | ||
| 71 | () => (selectedThread ? derivePendingUserInputs(selectedThread.activities) : []), | ||
| 72 | [selectedThread], | ||
| 73 | ); | ||
| 74 | const activePendingUserInput = activePendingUserInputs[0] ?? null; | ||
| 75 | const activePendingUserInputDrafts = | ||
| 76 | activePendingUserInput && selectedThreadShell | ||
| 77 | ? (userInputDraftsByRequestKey[ | ||
| 78 | scopedRequestKey(selectedThreadShell.environmentId, activePendingUserInput.requestId) | ||
| 79 | ] ?? {}) | ||
| 80 | : {}; | ||
| 81 | const activePendingUserInputAnswers = activePendingUserInput | ||
| 82 | ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts) | ||
| 83 | : null; | ||
| 84 | |||
| 85 | const onSelectUserInputOption = useCallback( | ||
| 86 | (requestId: ApprovalRequestId, questionId: string, label: string) => { | ||
| 87 | if (!selectedThreadShell) { | ||
| 88 | return; | ||
| 89 | } | ||
| 90 | |||
| 91 | const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); | ||
| 92 | setUserInputDraftOption(requestKey, questionId, label); | ||
| 93 | }, | ||
| 94 | [selectedThreadShell], | ||
| 95 | ); | ||
| 96 | |||
| 97 | const onChangeUserInputCustomAnswer = useCallback( | ||
| 98 | (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => { | ||
| 99 | if (!selectedThreadShell) { | ||
| 100 | return; | ||
| 101 | } | ||
| 102 | |||
| 103 | const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); | ||
| 104 | setUserInputDraftCustomAnswer(requestKey, questionId, customAnswer); | ||
| 105 | }, | ||
| 106 | [selectedThreadShell], | ||
| 107 | ); | ||
| 108 | |||
| 109 | const onRespondToApproval = useCallback( | ||
| 110 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { | ||
| 111 | if (!selectedThreadShell) { | ||
| 112 | return; | ||
| 113 | } | ||
| 114 | |||
| 115 | const client = getEnvironmentClient(selectedThreadShell.environmentId); | ||
| 116 | if (!client) { | ||
| 117 | return; | ||
| 118 | } | ||
| 119 | |||
| 120 | setRespondingApprovalId(requestId); | ||
| 121 | try { | ||
| 122 | await client.orchestration.dispatchCommand({ | ||
| 123 | type: "thread.approval.respond", | ||
| 124 | commandId: CommandId.make(uuidv4()), | ||
| 125 | threadId: selectedThreadShell.id, | ||
| 126 | requestId, | ||
| 127 | decision, | ||
| 128 | createdAt: new Date().toISOString(), | ||
| 129 | }); | ||
| 130 | } finally { | ||
| 131 | setRespondingApprovalId((current) => (current === requestId ? null : current)); | ||
| 132 | } | ||
| 133 | }, | ||
| 134 | [selectedThreadShell], | ||
| 135 | ); | ||
| 136 | |||
| 137 | const onSubmitUserInput = useCallback(async () => { | ||
| 138 | if (!selectedThreadShell || !activePendingUserInput || !activePendingUserInputAnswers) { | ||
| 139 | return; | ||
| 140 | } | ||
| 141 | |||
| 142 | const client = getEnvironmentClient(selectedThreadShell.environmentId); | ||
| 143 | if (!client) { | ||
| 144 | return; | ||
| 145 | } | ||
| 146 | |||
| 147 | setRespondingUserInputId(activePendingUserInput.requestId); | ||
| 148 | try { | ||
| 149 | await client.orchestration.dispatchCommand({ | ||
| 150 | type: "thread.user-input.respond", | ||
| 151 | commandId: CommandId.make(uuidv4()), | ||
| 152 | threadId: selectedThreadShell.id, | ||
| 153 | requestId: activePendingUserInput.requestId, | ||
| 154 | answers: activePendingUserInputAnswers, | ||
| 155 | createdAt: new Date().toISOString(), | ||
| 156 | }); | ||
| 157 | } finally { | ||
| 158 | setRespondingUserInputId((current) => | ||
| 159 | current === activePendingUserInput.requestId ? null : current, | ||
| 160 | ); | ||
| 161 | } | ||
| 162 | }, [activePendingUserInput, activePendingUserInputAnswers, selectedThreadShell]); | ||
| 163 | |||
| 164 | return { | ||
| 165 | activePendingApproval, | ||
| 166 | activePendingUserInput, | ||
| 167 | activePendingUserInputDrafts, | ||
| 168 | activePendingUserInputAnswers, | ||
| 169 | respondingApprovalId, | ||
| 170 | respondingUserInputId, | ||
| 171 | onRespondToApproval, | ||
| 172 | onSelectUserInputOption, | ||
| 173 | onChangeUserInputCustomAnswer, | ||
| 174 | onSubmitUserInput, | ||
| 175 | }; | ||
| 176 | } | ||
apps/mobile/src/state/use-selected-thread-worktree.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | |||
| 3 | import { useSelectedThreadDetail } from "./use-thread-detail"; | ||
| 4 | import { useThreadSelection } from "./use-thread-selection"; | ||
| 5 | import { resolvePreferredThreadWorktreePath } from "../features/terminal/terminalLaunchContext"; | ||
| 6 | |||
| 7 | export function useSelectedThreadWorktree() { | ||
| 8 | const { selectedThread, selectedThreadProject } = useThreadSelection(); | ||
| 9 | const selectedThreadDetail = useSelectedThreadDetail(); | ||
| 10 | |||
| 11 | const selectedThreadWorktreePath = useMemo( | ||
| 12 | () => | ||
| 13 | resolvePreferredThreadWorktreePath({ | ||
| 14 | threadShellWorktreePath: selectedThread?.worktreePath ?? null, | ||
| 15 | threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, | ||
| 16 | }), | ||
| 17 | [selectedThread?.worktreePath, selectedThreadDetail?.worktreePath], | ||
| 18 | ); | ||
| 19 | |||
| 20 | return { | ||
| 21 | selectedThreadWorktreePath, | ||
| 22 | selectedThreadCwd: selectedThreadWorktreePath ?? selectedThreadProject?.workspaceRoot ?? null, | ||
| 23 | }; | ||
| 24 | } | ||
apps/mobile/src/state/use-shell-snapshot.ts created+111| ... | @@ -0,0 +1,111 @@ | ||
| 1 | import * as Arr from "effect/Array"; | ||
| 2 | import * as Order from "effect/Order"; | ||
| 3 | import { useAtomValue } from "@effect/atom-react"; | ||
| 4 | import { Atom } from "effect/unstable/reactivity"; | ||
| 5 | import { | ||
| 6 | EMPTY_SHELL_SNAPSHOT_ATOM, | ||
| 7 | EMPTY_SHELL_SNAPSHOT_STATE, | ||
| 8 | createShellSnapshotManager, | ||
| 9 | getShellSnapshotTargetKey, | ||
| 10 | shellSnapshotStateAtom, | ||
| 11 | type ShellSnapshotState, | ||
| 12 | } from "@t3tools/client-runtime"; | ||
| 13 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 14 | import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; | ||
| 15 | |||
| 16 | import { appAtomRegistry } from "./atom-registry"; | ||
| 17 | import type { CachedShellSnapshot } from "../lib/storage"; | ||
| 18 | |||
| 19 | const cachedShellSnapshotMetadataAtom = Atom.make< | ||
| 20 | Readonly<Record<EnvironmentId, { readonly snapshotReceivedAt: string }>> | ||
| 21 | >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:cached-shell-snapshot-metadata")); | ||
| 22 | |||
| 23 | export const shellSnapshotManager = createShellSnapshotManager({ | ||
| 24 | getRegistry: () => appAtomRegistry, | ||
| 25 | }); | ||
| 26 | |||
| 27 | export function hydrateCachedShellSnapshot(cached: CachedShellSnapshot): void { | ||
| 28 | shellSnapshotManager.syncSnapshot({ environmentId: cached.environmentId }, cached.snapshot); | ||
| 29 | appAtomRegistry.set(cachedShellSnapshotMetadataAtom, { | ||
| 30 | ...appAtomRegistry.get(cachedShellSnapshotMetadataAtom), | ||
| 31 | [cached.environmentId]: { | ||
| 32 | snapshotReceivedAt: cached.snapshotReceivedAt, | ||
| 33 | }, | ||
| 34 | }); | ||
| 35 | } | ||
| 36 | |||
| 37 | export function markShellSnapshotLive(environmentId: EnvironmentId): void { | ||
| 38 | const current = appAtomRegistry.get(cachedShellSnapshotMetadataAtom); | ||
| 39 | if (current[environmentId] === undefined) { | ||
| 40 | return; | ||
| 41 | } | ||
| 42 | |||
| 43 | const next = { ...current }; | ||
| 44 | delete next[environmentId]; | ||
| 45 | appAtomRegistry.set(cachedShellSnapshotMetadataAtom, next); | ||
| 46 | } | ||
| 47 | |||
| 48 | export function clearCachedShellSnapshotMetadata(environmentId: EnvironmentId): void { | ||
| 49 | markShellSnapshotLive(environmentId); | ||
| 50 | } | ||
| 51 | |||
| 52 | export function useCachedShellSnapshotMetadata(): Readonly< | ||
| 53 | Record<EnvironmentId, { readonly snapshotReceivedAt: string }> | ||
| 54 | > { | ||
| 55 | return useAtomValue(cachedShellSnapshotMetadataAtom); | ||
| 56 | } | ||
| 57 | |||
| 58 | export function useShellSnapshot(environmentId: EnvironmentId | null): ShellSnapshotState { | ||
| 59 | const targetKey = getShellSnapshotTargetKey({ environmentId }); | ||
| 60 | const state = useAtomValue( | ||
| 61 | targetKey !== null ? shellSnapshotStateAtom(targetKey) : EMPTY_SHELL_SNAPSHOT_ATOM, | ||
| 62 | ); | ||
| 63 | return targetKey === null ? EMPTY_SHELL_SNAPSHOT_STATE : state; | ||
| 64 | } | ||
| 65 | |||
| 66 | export function useShellSnapshotStates( | ||
| 67 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 68 | ): Readonly<Record<EnvironmentId, ShellSnapshotState>> { | ||
| 69 | const stableEnvironmentIds = useMemo( | ||
| 70 | () => Arr.sort(new Set(environmentIds), Order.String), | ||
| 71 | [environmentIds], | ||
| 72 | ); | ||
| 73 | const snapshotCacheRef = useRef<Readonly<Record<EnvironmentId, ShellSnapshotState>>>({}); | ||
| 74 | |||
| 75 | const subscribe = useCallback( | ||
| 76 | (onStoreChange: () => void) => { | ||
| 77 | const unsubs = stableEnvironmentIds.map((environmentId) => | ||
| 78 | appAtomRegistry.subscribe(shellSnapshotStateAtom(environmentId), onStoreChange), | ||
| 79 | ); | ||
| 80 | return () => { | ||
| 81 | for (const unsub of unsubs) { | ||
| 82 | unsub(); | ||
| 83 | } | ||
| 84 | }; | ||
| 85 | }, | ||
| 86 | [stableEnvironmentIds], | ||
| 87 | ); | ||
| 88 | |||
| 89 | const getSnapshot = useCallback(() => { | ||
| 90 | const previous = snapshotCacheRef.current; | ||
| 91 | let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; | ||
| 92 | const next: Record<EnvironmentId, ShellSnapshotState> = {}; | ||
| 93 | |||
| 94 | for (const environmentId of stableEnvironmentIds) { | ||
| 95 | const snapshot = shellSnapshotManager.getSnapshot({ environmentId }); | ||
| 96 | next[environmentId] = snapshot; | ||
| 97 | if (!hasChanged && previous[environmentId] !== snapshot) { | ||
| 98 | hasChanged = true; | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | if (!hasChanged) { | ||
| 103 | return previous; | ||
| 104 | } | ||
| 105 | |||
| 106 | snapshotCacheRef.current = next; | ||
| 107 | return next; | ||
| 108 | }, [stableEnvironmentIds]); | ||
| 109 | |||
| 110 | return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); | ||
| 111 | } | ||
apps/mobile/src/state/use-source-control-discovery.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, | ||
| 4 | EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | ||
| 5 | type SourceControlDiscoveryClient, | ||
| 6 | type SourceControlDiscoveryState, | ||
| 7 | type SourceControlDiscoveryTarget, | ||
| 8 | createSourceControlDiscoveryManager, | ||
| 9 | getSourceControlDiscoveryTargetKey, | ||
| 10 | sourceControlDiscoveryStateAtom, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts"; | ||
| 13 | import { useEffect, useMemo } from "react"; | ||
| 14 | |||
| 15 | import { appAtomRegistry } from "./atom-registry"; | ||
| 16 | import { | ||
| 17 | getEnvironmentClient, | ||
| 18 | subscribeEnvironmentConnections, | ||
| 19 | } from "./environment-session-registry"; | ||
| 20 | |||
| 21 | const sourceControlDiscoveryManager = createSourceControlDiscoveryManager<EnvironmentId>({ | ||
| 22 | getRegistry: () => appAtomRegistry, | ||
| 23 | getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null, | ||
| 24 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 25 | }); | ||
| 26 | |||
| 27 | function sourceControlDiscoveryTargetForEnvironment( | ||
| 28 | environmentId: EnvironmentId | null, | ||
| 29 | ): SourceControlDiscoveryTarget<EnvironmentId> { | ||
| 30 | return { key: environmentId ?? null }; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function refreshSourceControlDiscoveryForEnvironment( | ||
| 34 | environmentId: EnvironmentId | null, | ||
| 35 | client?: SourceControlDiscoveryClient | null, | ||
| 36 | ): Promise<SourceControlDiscoveryResult | null> { | ||
| 37 | return sourceControlDiscoveryManager.refresh( | ||
| 38 | sourceControlDiscoveryTargetForEnvironment(environmentId), | ||
| 39 | client ?? undefined, | ||
| 40 | ); | ||
| 41 | } | ||
| 42 | |||
| 43 | export function invalidateSourceControlDiscoveryForEnvironment( | ||
| 44 | environmentId: EnvironmentId | null, | ||
| 45 | ): void { | ||
| 46 | sourceControlDiscoveryManager.invalidate( | ||
| 47 | sourceControlDiscoveryTargetForEnvironment(environmentId), | ||
| 48 | ); | ||
| 49 | } | ||
| 50 | |||
| 51 | export function resetSourceControlDiscoveryState(): void { | ||
| 52 | sourceControlDiscoveryManager.reset(); | ||
| 53 | } | ||
| 54 | |||
| 55 | export function resetSourceControlDiscoveryStateForTests(): void { | ||
| 56 | resetSourceControlDiscoveryState(); | ||
| 57 | } | ||
| 58 | |||
| 59 | export function useSourceControlDiscovery( | ||
| 60 | environmentId: EnvironmentId | null, | ||
| 61 | ): SourceControlDiscoveryState { | ||
| 62 | const target = useMemo( | ||
| 63 | () => sourceControlDiscoveryTargetForEnvironment(environmentId), | ||
| 64 | [environmentId], | ||
| 65 | ); | ||
| 66 | |||
| 67 | useEffect(() => { | ||
| 68 | return sourceControlDiscoveryManager.watch(target); | ||
| 69 | }, [target]); | ||
| 70 | |||
| 71 | const targetKey = getSourceControlDiscoveryTargetKey(target); | ||
| 72 | const state = useAtomValue( | ||
| 73 | targetKey !== null | ||
| 74 | ? sourceControlDiscoveryStateAtom(targetKey) | ||
| 75 | : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, | ||
| 76 | ); | ||
| 77 | return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; | ||
| 78 | } | ||
apps/mobile/src/state/use-terminal-session.ts created+84| ... | @@ -0,0 +1,84 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | createTerminalSessionManager, | ||
| 4 | EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, | ||
| 5 | EMPTY_TERMINAL_SESSION_ATOM, | ||
| 6 | getKnownTerminalSessionTarget, | ||
| 7 | getKnownTerminalSessionListFilter, | ||
| 8 | knownTerminalSessionsAtom, | ||
| 9 | terminalSessionStateAtom, | ||
| 10 | type TerminalSessionTarget, | ||
| 11 | type TerminalSessionState, | ||
| 12 | } from "@t3tools/client-runtime"; | ||
| 13 | import type { | ||
| 14 | EnvironmentId, | ||
| 15 | TerminalAttachInput, | ||
| 16 | TerminalAttachStreamEvent, | ||
| 17 | TerminalMetadataStreamEvent, | ||
| 18 | TerminalSessionSnapshot, | ||
| 19 | } from "@t3tools/contracts"; | ||
| 20 | import { useMemo } from "react"; | ||
| 21 | |||
| 22 | import { appAtomRegistry } from "./atom-registry"; | ||
| 23 | |||
| 24 | export const terminalSessionManager = createTerminalSessionManager({ | ||
| 25 | getRegistry: () => appAtomRegistry, | ||
| 26 | }); | ||
| 27 | |||
| 28 | export function subscribeTerminalMetadata(input: { | ||
| 29 | readonly environmentId: EnvironmentId; | ||
| 30 | readonly client: { | ||
| 31 | readonly terminal: { | ||
| 32 | readonly onMetadata: ( | ||
| 33 | listener: (event: TerminalMetadataStreamEvent) => void, | ||
| 34 | options?: { readonly onResubscribe?: () => void }, | ||
| 35 | ) => () => void; | ||
| 36 | }; | ||
| 37 | }; | ||
| 38 | }) { | ||
| 39 | return terminalSessionManager.subscribeMetadata(input); | ||
| 40 | } | ||
| 41 | |||
| 42 | export function attachTerminalSession(input: { | ||
| 43 | readonly environmentId: EnvironmentId; | ||
| 44 | readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"]; | ||
| 45 | readonly terminal: TerminalAttachInput; | ||
| 46 | readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; | ||
| 47 | readonly onEvent?: (event: TerminalAttachStreamEvent) => void; | ||
| 48 | }) { | ||
| 49 | return terminalSessionManager.attach({ | ||
| 50 | environmentId: input.environmentId, | ||
| 51 | client: input.client, | ||
| 52 | terminal: input.terminal, | ||
| 53 | ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), | ||
| 54 | ...(input.onEvent ? { onEvent: input.onEvent } : {}), | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | |||
| 58 | export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState { | ||
| 59 | const target = getKnownTerminalSessionTarget(input); | ||
| 60 | return useAtomValue( | ||
| 61 | target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM, | ||
| 62 | ); | ||
| 63 | } | ||
| 64 | |||
| 65 | export function useTerminalSessionTarget(input: TerminalSessionTarget) { | ||
| 66 | return useMemo( | ||
| 67 | () => ({ | ||
| 68 | environmentId: input.environmentId, | ||
| 69 | threadId: input.threadId, | ||
| 70 | terminalId: input.terminalId, | ||
| 71 | }), | ||
| 72 | [input.environmentId, input.threadId, input.terminalId], | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | |||
| 76 | export function useKnownTerminalSessions(input: { | ||
| 77 | readonly environmentId: TerminalSessionTarget["environmentId"]; | ||
| 78 | readonly threadId: TerminalSessionTarget["threadId"]; | ||
| 79 | }) { | ||
| 80 | const filter = getKnownTerminalSessionListFilter(input); | ||
| 81 | return useAtomValue( | ||
| 82 | filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, | ||
| 83 | ); | ||
| 84 | } | ||
apps/mobile/src/state/use-thread-composer-state.ts created+469| ... | @@ -0,0 +1,469 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useCallback, useEffect, useMemo } from "react"; | ||
| 3 | |||
| 4 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; | ||
| 5 | import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; | ||
| 6 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; | ||
| 7 | import { Atom } from "effect/unstable/reactivity"; | ||
| 8 | |||
| 9 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; | ||
| 10 | import { | ||
| 11 | convertPastedImagesToAttachments, | ||
| 12 | pasteComposerClipboard, | ||
| 13 | pickComposerImages, | ||
| 14 | } from "../lib/composerImages"; | ||
| 15 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | ||
| 16 | import { scopedThreadKey } from "../lib/scopedEntities"; | ||
| 17 | import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity"; | ||
| 18 | import { appAtomRegistry } from "../state/atom-registry"; | ||
| 19 | import { getEnvironmentClient } from "./environment-session-registry"; | ||
| 20 | import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types"; | ||
| 21 | import { | ||
| 22 | setPendingConnectionError, | ||
| 23 | useRemoteConnectionStatus, | ||
| 24 | } from "../state/use-remote-environment-registry"; | ||
| 25 | import { useRemoteCatalog } from "../state/use-remote-catalog"; | ||
| 26 | import { useSelectedThreadDetail } from "../state/use-thread-detail"; | ||
| 27 | import { useThreadSelection } from "../state/use-thread-selection"; | ||
| 28 | |||
| 29 | const draftMessageByThreadKeyAtom = Atom.make<Record<string, string>>({}).pipe( | ||
| 30 | Atom.keepAlive, | ||
| 31 | Atom.withLabel("mobile:thread-composer:draft-message"), | ||
| 32 | ); | ||
| 33 | |||
| 34 | const draftAttachmentsByThreadKeyAtom = Atom.make< | ||
| 35 | Record<string, ReadonlyArray<DraftComposerImageAttachment>> | ||
| 36 | >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:draft-attachments")); | ||
| 37 | |||
| 38 | const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe( | ||
| 39 | Atom.keepAlive, | ||
| 40 | Atom.withLabel("mobile:thread-composer:dispatching-message-id"), | ||
| 41 | ); | ||
| 42 | |||
| 43 | const queuedMessagesByThreadKeyAtom = Atom.make<Record<string, ReadonlyArray<QueuedThreadMessage>>>( | ||
| 44 | {}, | ||
| 45 | ).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages")); | ||
| 46 | |||
| 47 | function setDraftMessage(threadKey: string, value: string): void { | ||
| 48 | const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); | ||
| 49 | appAtomRegistry.set(draftMessageByThreadKeyAtom, { | ||
| 50 | ...current, | ||
| 51 | [threadKey]: value, | ||
| 52 | }); | ||
| 53 | } | ||
| 54 | |||
| 55 | function appendDraftAttachments( | ||
| 56 | threadKey: string, | ||
| 57 | attachments: ReadonlyArray<DraftComposerImageAttachment>, | ||
| 58 | ): void { | ||
| 59 | const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); | ||
| 60 | appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { | ||
| 61 | ...current, | ||
| 62 | [threadKey]: [...(current[threadKey] ?? []), ...attachments], | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | |||
| 66 | function appendDraftMessage(threadKey: string, value: string): void { | ||
| 67 | const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); | ||
| 68 | appAtomRegistry.set(draftMessageByThreadKeyAtom, { | ||
| 69 | ...current, | ||
| 70 | [threadKey]: `${current[threadKey] ?? ""}${value}`, | ||
| 71 | }); | ||
| 72 | } | ||
| 73 | |||
| 74 | export function appendReviewCommentToDraft(input: { | ||
| 75 | readonly environmentId: EnvironmentId; | ||
| 76 | readonly threadId: ThreadId; | ||
| 77 | readonly text: string; | ||
| 78 | readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 79 | }): void { | ||
| 80 | const threadKey = scopedThreadKey(input.environmentId, input.threadId); | ||
| 81 | const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); | ||
| 82 | const existing = current[threadKey] ?? ""; | ||
| 83 | const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : ""; | ||
| 84 | appAtomRegistry.set(draftMessageByThreadKeyAtom, { | ||
| 85 | ...current, | ||
| 86 | [threadKey]: `${existing}${separator}${input.text}`, | ||
| 87 | }); | ||
| 88 | if (input.attachments && input.attachments.length > 0) { | ||
| 89 | appendDraftAttachments(threadKey, input.attachments); | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | export function useThreadDraftForThread(input: { | ||
| 94 | readonly environmentId?: EnvironmentId; | ||
| 95 | readonly threadId?: ThreadId; | ||
| 96 | }) { | ||
| 97 | const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom); | ||
| 98 | const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom); | ||
| 99 | const threadKey = | ||
| 100 | input.environmentId && input.threadId | ||
| 101 | ? scopedThreadKey(input.environmentId, input.threadId) | ||
| 102 | : null; | ||
| 103 | |||
| 104 | return { | ||
| 105 | draftMessage: threadKey ? (draftMessageByThreadKey[threadKey] ?? "") : "", | ||
| 106 | draftAttachments: threadKey ? (draftAttachmentsByThreadKey[threadKey] ?? []) : [], | ||
| 107 | }; | ||
| 108 | } | ||
| 109 | |||
| 110 | function clearDraft(threadKey: string): void { | ||
| 111 | const draftMessages = appAtomRegistry.get(draftMessageByThreadKeyAtom); | ||
| 112 | const draftAttachments = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); | ||
| 113 | appAtomRegistry.set(draftMessageByThreadKeyAtom, { | ||
| 114 | ...draftMessages, | ||
| 115 | [threadKey]: "", | ||
| 116 | }); | ||
| 117 | appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { | ||
| 118 | ...draftAttachments, | ||
| 119 | [threadKey]: [], | ||
| 120 | }); | ||
| 121 | } | ||
| 122 | |||
| 123 | function removeDraftImage(threadKey: string, imageId: string): void { | ||
| 124 | const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); | ||
| 125 | appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { | ||
| 126 | ...current, | ||
| 127 | [threadKey]: (current[threadKey] ?? []).filter((image) => image.id !== imageId), | ||
| 128 | }); | ||
| 129 | } | ||
| 130 | |||
| 131 | function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { | ||
| 132 | appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); | ||
| 133 | } | ||
| 134 | |||
| 135 | function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { | ||
| 136 | const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom); | ||
| 137 | appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current); | ||
| 138 | } | ||
| 139 | |||
| 140 | function enqueueQueuedMessage(message: QueuedThreadMessage): void { | ||
| 141 | const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); | ||
| 142 | const threadKey = scopedThreadKey(message.environmentId, message.threadId); | ||
| 143 | appAtomRegistry.set(queuedMessagesByThreadKeyAtom, { | ||
| 144 | ...current, | ||
| 145 | [threadKey]: [...(current[threadKey] ?? []), message], | ||
| 146 | }); | ||
| 147 | } | ||
| 148 | |||
| 149 | function removeQueuedMessage( | ||
| 150 | environmentId: EnvironmentId, | ||
| 151 | threadId: ThreadId, | ||
| 152 | queuedMessageId: MessageId, | ||
| 153 | ): void { | ||
| 154 | const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); | ||
| 155 | const threadKey = scopedThreadKey(environmentId, threadId); | ||
| 156 | const existing = current[threadKey]; | ||
| 157 | if (!existing) { | ||
| 158 | return; | ||
| 159 | } | ||
| 160 | |||
| 161 | const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId); | ||
| 162 | const next = { ...current }; | ||
| 163 | if (nextQueue.length === 0) { | ||
| 164 | delete next[threadKey]; | ||
| 165 | } else { | ||
| 166 | next[threadKey] = nextQueue; | ||
| 167 | } | ||
| 168 | |||
| 169 | appAtomRegistry.set(queuedMessagesByThreadKeyAtom, next); | ||
| 170 | } | ||
| 171 | |||
| 172 | function useQueueDrain(input: { | ||
| 173 | readonly dispatchingQueuedMessageId: MessageId | null; | ||
| 174 | readonly queuedMessagesByThreadKey: Record<string, ReadonlyArray<QueuedThreadMessage>>; | ||
| 175 | readonly threads: ReadonlyArray<EnvironmentScopedThreadShell>; | ||
| 176 | readonly environments: ReadonlyArray<ConnectedEnvironmentSummary>; | ||
| 177 | readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise<void>; | ||
| 178 | }) { | ||
| 179 | const { | ||
| 180 | dispatchingQueuedMessageId, | ||
| 181 | environments, | ||
| 182 | queuedMessagesByThreadKey, | ||
| 183 | sendQueuedMessage, | ||
| 184 | threads, | ||
| 185 | } = input; | ||
| 186 | |||
| 187 | useEffect(() => { | ||
| 188 | if (dispatchingQueuedMessageId !== null) { | ||
| 189 | return; | ||
| 190 | } | ||
| 191 | |||
| 192 | for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { | ||
| 193 | const nextQueuedMessage = queuedMessages[0]; | ||
| 194 | if (!nextQueuedMessage) { | ||
| 195 | continue; | ||
| 196 | } | ||
| 197 | |||
| 198 | const thread = threads.find( | ||
| 199 | (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === threadKey, | ||
| 200 | ); | ||
| 201 | if (!thread) { | ||
| 202 | continue; | ||
| 203 | } | ||
| 204 | |||
| 205 | const environment = environments.find( | ||
| 206 | (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, | ||
| 207 | ); | ||
| 208 | if (!environment || environment.connectionState !== "ready") { | ||
| 209 | continue; | ||
| 210 | } | ||
| 211 | |||
| 212 | const threadStatus = thread.session?.status; | ||
| 213 | if (threadStatus === "running" || threadStatus === "starting") { | ||
| 214 | continue; | ||
| 215 | } | ||
| 216 | |||
| 217 | void sendQueuedMessage(nextQueuedMessage); | ||
| 218 | return; | ||
| 219 | } | ||
| 220 | }, [ | ||
| 221 | dispatchingQueuedMessageId, | ||
| 222 | environments, | ||
| 223 | queuedMessagesByThreadKey, | ||
| 224 | sendQueuedMessage, | ||
| 225 | threads, | ||
| 226 | ]); | ||
| 227 | } | ||
| 228 | |||
| 229 | export function useThreadComposerState() { | ||
| 230 | const { connectedEnvironments } = useRemoteConnectionStatus(); | ||
| 231 | const { threads } = useRemoteCatalog(); | ||
| 232 | const { selectedThread: selectedThreadShell } = useThreadSelection(); | ||
| 233 | const selectedThread = useSelectedThreadDetail(); | ||
| 234 | const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom); | ||
| 235 | const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom); | ||
| 236 | const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); | ||
| 237 | const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom); | ||
| 238 | |||
| 239 | const selectedThreadKey = selectedThreadShell | ||
| 240 | ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) | ||
| 241 | : null; | ||
| 242 | const selectedThreadQueuedMessages = useMemo( | ||
| 243 | () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), | ||
| 244 | [queuedMessagesByThreadKey, selectedThreadKey], | ||
| 245 | ); | ||
| 246 | |||
| 247 | const selectedThreadFeed = useMemo( | ||
| 248 | () => | ||
| 249 | selectedThread | ||
| 250 | ? buildThreadFeed(selectedThread, selectedThreadQueuedMessages, dispatchingQueuedMessageId) | ||
| 251 | : [], | ||
| 252 | [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages], | ||
| 253 | ); | ||
| 254 | |||
| 255 | const draftMessage = selectedThreadKey ? (draftMessageByThreadKey[selectedThreadKey] ?? "") : ""; | ||
| 256 | const draftAttachments = selectedThreadKey | ||
| 257 | ? (draftAttachmentsByThreadKey[selectedThreadKey] ?? []) | ||
| 258 | : []; | ||
| 259 | const selectedThreadQueueCount = selectedThreadQueuedMessages.length; | ||
| 260 | |||
| 261 | const selectedThreadSessionActivity = useMemo(() => { | ||
| 262 | if (!selectedThread?.session) { | ||
| 263 | return null; | ||
| 264 | } | ||
| 265 | |||
| 266 | return { | ||
| 267 | orchestrationStatus: selectedThread.session.status, | ||
| 268 | activeTurnId: selectedThread.session.activeTurnId ?? undefined, | ||
| 269 | }; | ||
| 270 | }, [selectedThread]); | ||
| 271 | |||
| 272 | const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; | ||
| 273 | const activeWorkStartedAt = useMemo(() => { | ||
| 274 | if (!selectedThread) { | ||
| 275 | return null; | ||
| 276 | } | ||
| 277 | |||
| 278 | return deriveActiveWorkStartedAt( | ||
| 279 | selectedThread.latestTurn, | ||
| 280 | selectedThreadSessionActivity, | ||
| 281 | queuedSendStartedAt, | ||
| 282 | ); | ||
| 283 | }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]); | ||
| 284 | |||
| 285 | const activeThreadBusy = | ||
| 286 | !!selectedThread && | ||
| 287 | (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); | ||
| 288 | |||
| 289 | const sendQueuedMessage = useCallback( | ||
| 290 | async (queuedMessage: QueuedThreadMessage) => { | ||
| 291 | const client = getEnvironmentClient(queuedMessage.environmentId); | ||
| 292 | const thread = threads.find( | ||
| 293 | (candidate) => | ||
| 294 | candidate.environmentId === queuedMessage.environmentId && | ||
| 295 | candidate.id === queuedMessage.threadId, | ||
| 296 | ); | ||
| 297 | if (!client || !thread) { | ||
| 298 | return; | ||
| 299 | } | ||
| 300 | |||
| 301 | beginDispatchingQueuedMessage(queuedMessage.messageId); | ||
| 302 | try { | ||
| 303 | await client.orchestration.dispatchCommand({ | ||
| 304 | type: "thread.turn.start", | ||
| 305 | commandId: queuedMessage.commandId, | ||
| 306 | threadId: queuedMessage.threadId, | ||
| 307 | message: { | ||
| 308 | messageId: queuedMessage.messageId, | ||
| 309 | role: "user", | ||
| 310 | text: queuedMessage.text, | ||
| 311 | attachments: queuedMessage.attachments, | ||
| 312 | }, | ||
| 313 | runtimeMode: thread.runtimeMode, | ||
| 314 | interactionMode: thread.interactionMode, | ||
| 315 | createdAt: queuedMessage.createdAt, | ||
| 316 | }); | ||
| 317 | |||
| 318 | removeQueuedMessage( | ||
| 319 | queuedMessage.environmentId, | ||
| 320 | queuedMessage.threadId, | ||
| 321 | queuedMessage.messageId, | ||
| 322 | ); | ||
| 323 | } catch (error) { | ||
| 324 | removeQueuedMessage( | ||
| 325 | queuedMessage.environmentId, | ||
| 326 | queuedMessage.threadId, | ||
| 327 | queuedMessage.messageId, | ||
| 328 | ); | ||
| 329 | setPendingConnectionError( | ||
| 330 | error instanceof Error ? error.message : "Failed to send message.", | ||
| 331 | ); | ||
| 332 | } finally { | ||
| 333 | finishDispatchingQueuedMessage(queuedMessage.messageId); | ||
| 334 | } | ||
| 335 | }, | ||
| 336 | [threads], | ||
| 337 | ); | ||
| 338 | |||
| 339 | useQueueDrain({ | ||
| 340 | dispatchingQueuedMessageId, | ||
| 341 | queuedMessagesByThreadKey, | ||
| 342 | threads, | ||
| 343 | environments: connectedEnvironments, | ||
| 344 | sendQueuedMessage, | ||
| 345 | }); | ||
| 346 | |||
| 347 | const onSendMessage = useCallback(() => { | ||
| 348 | if (!selectedThreadShell) { | ||
| 349 | return; | ||
| 350 | } | ||
| 351 | |||
| 352 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | ||
| 353 | const text = (draftMessageByThreadKey[threadKey] ?? "").trim(); | ||
| 354 | const attachments = draftAttachmentsByThreadKey[threadKey] ?? []; | ||
| 355 | if (text.length === 0 && attachments.length === 0) { | ||
| 356 | return; | ||
| 357 | } | ||
| 358 | |||
| 359 | const metadata = makeQueuedMessageMetadata(); | ||
| 360 | enqueueQueuedMessage({ | ||
| 361 | environmentId: selectedThreadShell.environmentId, | ||
| 362 | threadId: selectedThreadShell.id, | ||
| 363 | messageId: MessageId.make(metadata.messageId), | ||
| 364 | commandId: CommandId.make(metadata.commandId), | ||
| 365 | text, | ||
| 366 | attachments, | ||
| 367 | createdAt: metadata.createdAt, | ||
| 368 | }); | ||
| 369 | clearDraft(threadKey); | ||
| 370 | }, [draftAttachmentsByThreadKey, draftMessageByThreadKey, selectedThreadShell]); | ||
| 371 | |||
| 372 | const onChangeDraftMessage = useCallback( | ||
| 373 | (value: string) => { | ||
| 374 | if (!selectedThreadShell) { | ||
| 375 | return; | ||
| 376 | } | ||
| 377 | |||
| 378 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | ||
| 379 | setDraftMessage(threadKey, value); | ||
| 380 | }, | ||
| 381 | [selectedThreadShell], | ||
| 382 | ); | ||
| 383 | |||
| 384 | const onPickDraftImages = useCallback(async () => { | ||
| 385 | if (!selectedThreadShell) { | ||
| 386 | return; | ||
| 387 | } | ||
| 388 | |||
| 389 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | ||
| 390 | const result = await pickComposerImages({ | ||
| 391 | existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, | ||
| 392 | }); | ||
| 393 | if (result.images.length > 0) { | ||
| 394 | appendDraftAttachments(threadKey, result.images); | ||
| 395 | } | ||
| 396 | if (result.error) { | ||
| 397 | setPendingConnectionError(result.error); | ||
| 398 | } | ||
| 399 | }, [draftAttachmentsByThreadKey, selectedThreadShell]); | ||
| 400 | |||
| 401 | const onPasteIntoDraft = useCallback(async () => { | ||
| 402 | if (!selectedThreadShell) { | ||
| 403 | return; | ||
| 404 | } | ||
| 405 | |||
| 406 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | ||
| 407 | const result = await pasteComposerClipboard({ | ||
| 408 | existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, | ||
| 409 | }); | ||
| 410 | if (result.images.length > 0) { | ||
| 411 | appendDraftAttachments(threadKey, result.images); | ||
| 412 | } | ||
| 413 | if (result.text) { | ||
| 414 | appendDraftMessage(threadKey, result.text); | ||
| 415 | } | ||
| 416 | if (result.error) { | ||
| 417 | setPendingConnectionError(result.error); | ||
| 418 | } | ||
| 419 | }, [draftAttachmentsByThreadKey, selectedThreadShell]); | ||
| 420 | |||
| 421 | const onNativePasteImages = useCallback( | ||
| 422 | async (uris: ReadonlyArray<string>) => { | ||
| 423 | if (!selectedThreadShell || uris.length === 0) { | ||
| 424 | return; | ||
| 425 | } | ||
| 426 | |||
| 427 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | ||
| 428 | try { | ||
| 429 | const images = await convertPastedImagesToAttachments({ | ||
| 430 | uris, | ||
| 431 | existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, | ||
| 432 | }); | ||
| 433 | if (images.length > 0) { | ||
| 434 | appendDraftAttachments(threadKey, images); | ||
| 435 | } | ||
| 436 | } catch (error) { | ||
| 437 | console.error("[native paste] error converting images", error); | ||
| 438 | } | ||
| 439 | }, | ||
| 440 | [draftAttachmentsByThreadKey, selectedThreadShell], | ||
| 441 | ); | ||
| 442 | |||
| 443 | const onRemoveDraftImage = useCallback( | ||
| 444 | (imageId: string) => { | ||
| 445 | if (!selectedThreadShell) { | ||
| 446 | return; | ||
| 447 | } | ||
| 448 | |||
| 449 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | ||
| 450 | removeDraftImage(threadKey, imageId); | ||
| 451 | }, | ||
| 452 | [selectedThreadShell], | ||
| 453 | ); | ||
| 454 | |||
| 455 | return { | ||
| 456 | selectedThreadFeed, | ||
| 457 | selectedThreadQueueCount, | ||
| 458 | activeWorkStartedAt, | ||
| 459 | draftMessage, | ||
| 460 | draftAttachments, | ||
| 461 | activeThreadBusy, | ||
| 462 | onChangeDraftMessage, | ||
| 463 | onPickDraftImages, | ||
| 464 | onPasteIntoDraft, | ||
| 465 | onNativePasteImages, | ||
| 466 | onRemoveDraftImage, | ||
| 467 | onSendMessage, | ||
| 468 | }; | ||
| 469 | } | ||
apps/mobile/src/state/use-thread-detail.ts created+82| ... | @@ -0,0 +1,82 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | EMPTY_THREAD_DETAIL_ATOM, | ||
| 4 | EMPTY_THREAD_DETAIL_STATE, | ||
| 5 | createThreadDetailManager, | ||
| 6 | getThreadDetailTargetKey, | ||
| 7 | threadDetailStateAtom, | ||
| 8 | type ThreadDetailState, | ||
| 9 | type ThreadDetailTarget, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { useEffect, useMemo } from "react"; | ||
| 12 | |||
| 13 | import { derivePendingApprovals, derivePendingUserInputs } from "../lib/threadActivity"; | ||
| 14 | import { appAtomRegistry } from "./atom-registry"; | ||
| 15 | import { | ||
| 16 | getEnvironmentClient, | ||
| 17 | subscribeEnvironmentConnections, | ||
| 18 | } from "./environment-session-registry"; | ||
| 19 | import { useThreadSelection } from "./use-thread-selection"; | ||
| 20 | |||
| 21 | function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean { | ||
| 22 | const thread = state.data; | ||
| 23 | if (!thread || state.isDeleted) { | ||
| 24 | return false; | ||
| 25 | } | ||
| 26 | |||
| 27 | if (thread.latestTurn?.sourceProposedPlan) { | ||
| 28 | return true; | ||
| 29 | } | ||
| 30 | |||
| 31 | const sessionStatus = thread.session?.status; | ||
| 32 | if (sessionStatus && sessionStatus !== "idle" && sessionStatus !== "stopped") { | ||
| 33 | return true; | ||
| 34 | } | ||
| 35 | |||
| 36 | return ( | ||
| 37 | derivePendingApprovals(thread.activities).length > 0 || | ||
| 38 | derivePendingUserInputs(thread.activities).length > 0 | ||
| 39 | ); | ||
| 40 | } | ||
| 41 | |||
| 42 | const threadDetailManager = createThreadDetailManager({ | ||
| 43 | getRegistry: () => appAtomRegistry, | ||
| 44 | getClient: (environmentId) => { | ||
| 45 | const client = getEnvironmentClient(environmentId); | ||
| 46 | return client ? client.orchestration : null; | ||
| 47 | }, | ||
| 48 | getClientIdentity: (environmentId) => { | ||
| 49 | return getEnvironmentClient(environmentId) ? environmentId : null; | ||
| 50 | }, | ||
| 51 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 52 | retention: { | ||
| 53 | idleTtlMs: 5 * 60 * 1_000, | ||
| 54 | maxRetainedEntries: 24, | ||
| 55 | shouldKeepWarm: (_target, state) => shouldKeepThreadDetailWarm(state), | ||
| 56 | }, | ||
| 57 | }); | ||
| 58 | |||
| 59 | export function useThreadDetail(target: ThreadDetailTarget): ThreadDetailState { | ||
| 60 | const { environmentId, threadId } = target; | ||
| 61 | const targetKey = getThreadDetailTargetKey(target); | ||
| 62 | |||
| 63 | useEffect( | ||
| 64 | () => threadDetailManager.watch({ environmentId, threadId }), | ||
| 65 | [environmentId, threadId], | ||
| 66 | ); | ||
| 67 | |||
| 68 | const state = useAtomValue( | ||
| 69 | targetKey !== null ? threadDetailStateAtom(targetKey) : EMPTY_THREAD_DETAIL_ATOM, | ||
| 70 | ); | ||
| 71 | return targetKey === null ? EMPTY_THREAD_DETAIL_STATE : state; | ||
| 72 | } | ||
| 73 | |||
| 74 | export function useSelectedThreadDetail() { | ||
| 75 | const { selectedThread } = useThreadSelection(); | ||
| 76 | const state = useThreadDetail({ | ||
| 77 | environmentId: selectedThread?.environmentId ?? null, | ||
| 78 | threadId: selectedThread?.id ?? null, | ||
| 79 | }); | ||
| 80 | |||
| 81 | return useMemo(() => state.data, [state.data]); | ||
| 82 | } | ||
apps/mobile/src/state/use-thread-selection.ts created+95| ... | @@ -0,0 +1,95 @@ | ||
| 1 | import { useLocalSearchParams } from "expo-router"; | ||
| 2 | import { useMemo } from "react"; | ||
| 3 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; | ||
| 6 | import { EnvironmentScopedProjectShell } from "@t3tools/client-runtime"; | ||
| 7 | import { useRemoteCatalog } from "./use-remote-catalog"; | ||
| 8 | import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; | ||
| 9 | |||
| 10 | function firstRouteParam(value: string | string[] | undefined): string | null { | ||
| 11 | if (Array.isArray(value)) { | ||
| 12 | return value[0] ?? null; | ||
| 13 | } | ||
| 14 | |||
| 15 | return value ?? null; | ||
| 16 | } | ||
| 17 | |||
| 18 | function deriveSelectedThread( | ||
| 19 | selectedThreadRef: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null, | ||
| 20 | threads: ReadonlyArray<EnvironmentScopedThreadShell>, | ||
| 21 | ): EnvironmentScopedThreadShell | null { | ||
| 22 | if (!selectedThreadRef) { | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | |||
| 26 | return ( | ||
| 27 | threads.find( | ||
| 28 | (thread) => | ||
| 29 | thread.environmentId === selectedThreadRef.environmentId && | ||
| 30 | thread.id === selectedThreadRef.threadId, | ||
| 31 | ) ?? null | ||
| 32 | ); | ||
| 33 | } | ||
| 34 | |||
| 35 | function deriveSelectedThreadProject( | ||
| 36 | selectedThread: EnvironmentScopedThreadShell | null, | ||
| 37 | projects: ReadonlyArray<EnvironmentScopedProjectShell>, | ||
| 38 | ): EnvironmentScopedProjectShell | null { | ||
| 39 | if (!selectedThread) { | ||
| 40 | return null; | ||
| 41 | } | ||
| 42 | |||
| 43 | return ( | ||
| 44 | projects.find( | ||
| 45 | (project) => | ||
| 46 | project.environmentId === selectedThread.environmentId && | ||
| 47 | project.id === selectedThread.projectId, | ||
| 48 | ) ?? null | ||
| 49 | ); | ||
| 50 | } | ||
| 51 | |||
| 52 | export function useThreadSelection() { | ||
| 53 | const { projects, threads } = useRemoteCatalog(); | ||
| 54 | const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 55 | const params = useLocalSearchParams<{ | ||
| 56 | environmentId?: string | string[]; | ||
| 57 | threadId?: string | string[]; | ||
| 58 | }>(); | ||
| 59 | const selectedThreadRef = useMemo(() => { | ||
| 60 | const environmentId = firstRouteParam(params.environmentId); | ||
| 61 | const threadId = firstRouteParam(params.threadId); | ||
| 62 | if (!environmentId || !threadId) { | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | |||
| 66 | return { | ||
| 67 | environmentId: EnvironmentId.make(environmentId), | ||
| 68 | threadId: ThreadId.make(threadId), | ||
| 69 | }; | ||
| 70 | }, [params.environmentId, params.threadId]); | ||
| 71 | const selectedThread = useMemo( | ||
| 72 | () => deriveSelectedThread(selectedThreadRef, threads), | ||
| 73 | [selectedThreadRef, threads], | ||
| 74 | ); | ||
| 75 | |||
| 76 | const selectedThreadProject = useMemo( | ||
| 77 | () => deriveSelectedThreadProject(selectedThread, projects), | ||
| 78 | [projects, selectedThread], | ||
| 79 | ); | ||
| 80 | |||
| 81 | const selectedEnvironmentConnection = selectedThread | ||
| 82 | ? (savedConnectionsById[selectedThread.environmentId] ?? null) | ||
| 83 | : null; | ||
| 84 | const selectedEnvironmentRuntime = selectedThread | ||
| 85 | ? (environmentStateById[selectedThread.environmentId] ?? null) | ||
| 86 | : null; | ||
| 87 | |||
| 88 | return { | ||
| 89 | selectedThreadRef, | ||
| 90 | selectedThread, | ||
| 91 | selectedThreadProject, | ||
| 92 | selectedEnvironmentConnection, | ||
| 93 | selectedEnvironmentRuntime, | ||
| 94 | }; | ||
| 95 | } | ||
apps/mobile/src/state/use-vcs-action-state.ts created+162| ... | @@ -0,0 +1,162 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type VcsActionState, | ||
| 4 | type VcsActionTarget, | ||
| 5 | EMPTY_VCS_ACTION_ATOM, | ||
| 6 | EMPTY_VCS_ACTION_STATE, | ||
| 7 | createVcsActionManager, | ||
| 8 | getVcsActionTargetKey, | ||
| 9 | vcsActionStateAtom, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 12 | |||
| 13 | import { uuidv4 } from "../lib/uuid"; | ||
| 14 | import { appAtomRegistry } from "./atom-registry"; | ||
| 15 | import { getEnvironmentClient } from "./environment-session-registry"; | ||
| 16 | |||
| 17 | export const vcsActionManager = createVcsActionManager({ | ||
| 18 | getRegistry: () => appAtomRegistry, | ||
| 19 | getClient: (environmentId) => { | ||
| 20 | const client = getEnvironmentClient(environmentId); | ||
| 21 | return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; | ||
| 22 | }, | ||
| 23 | getActionId: uuidv4, | ||
| 24 | }); | ||
| 25 | |||
| 26 | export function useVcsActionState(target: VcsActionTarget): VcsActionState { | ||
| 27 | const targetKey = getVcsActionTargetKey(target); | ||
| 28 | const state = useAtomValue( | ||
| 29 | targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, | ||
| 30 | ); | ||
| 31 | return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; | ||
| 32 | } | ||
| 33 | |||
| 34 | // --------------------------------------------------------------------------- | ||
| 35 | // Git action result notification | ||
| 36 | // --------------------------------------------------------------------------- | ||
| 37 | |||
| 38 | export interface GitActionResultNotification { | ||
| 39 | readonly type: "success" | "error"; | ||
| 40 | readonly title: string; | ||
| 41 | readonly description?: string; | ||
| 42 | readonly prUrl?: string; | ||
| 43 | } | ||
| 44 | |||
| 45 | const RESULT_DISMISS_MS = 5_000; | ||
| 46 | |||
| 47 | type ResultListener = (result: GitActionResultNotification | null) => void; | ||
| 48 | const resultListeners = new Set<ResultListener>(); | ||
| 49 | let currentResult: GitActionResultNotification | null = null; | ||
| 50 | let dismissTimer: ReturnType<typeof setTimeout> | null = null; | ||
| 51 | |||
| 52 | function broadcast(result: GitActionResultNotification | null): void { | ||
| 53 | currentResult = result; | ||
| 54 | for (const listener of resultListeners) { | ||
| 55 | listener(result); | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | export function showGitActionResult(result: GitActionResultNotification): void { | ||
| 60 | if (dismissTimer) clearTimeout(dismissTimer); | ||
| 61 | broadcast(result); | ||
| 62 | dismissTimer = setTimeout(() => broadcast(null), RESULT_DISMISS_MS); | ||
| 63 | } | ||
| 64 | |||
| 65 | export function dismissGitActionResult(): void { | ||
| 66 | if (dismissTimer) clearTimeout(dismissTimer); | ||
| 67 | broadcast(null); | ||
| 68 | } | ||
| 69 | |||
| 70 | export function useGitActionResultNotification(): { | ||
| 71 | readonly result: GitActionResultNotification | null; | ||
| 72 | readonly dismiss: () => void; | ||
| 73 | } { | ||
| 74 | const [result, setResult] = useState<GitActionResultNotification | null>(currentResult); | ||
| 75 | |||
| 76 | useEffect(() => { | ||
| 77 | resultListeners.add(setResult); | ||
| 78 | setResult(currentResult); | ||
| 79 | return () => { | ||
| 80 | resultListeners.delete(setResult); | ||
| 81 | }; | ||
| 82 | }, []); | ||
| 83 | |||
| 84 | return { result, dismiss: dismissGitActionResult }; | ||
| 85 | } | ||
| 86 | |||
| 87 | // --------------------------------------------------------------------------- | ||
| 88 | // Unified git action progress (combines running state + result notification) | ||
| 89 | // --------------------------------------------------------------------------- | ||
| 90 | |||
| 91 | export type GitActionProgressPhase = "idle" | "running" | "success" | "error"; | ||
| 92 | |||
| 93 | export interface GitActionProgress { | ||
| 94 | readonly phase: GitActionProgressPhase; | ||
| 95 | readonly label: string | null; | ||
| 96 | readonly description: string | null; | ||
| 97 | readonly prUrl?: string; | ||
| 98 | } | ||
| 99 | |||
| 100 | const EMPTY_PROGRESS: GitActionProgress = { | ||
| 101 | phase: "idle", | ||
| 102 | label: null, | ||
| 103 | description: null, | ||
| 104 | }; | ||
| 105 | |||
| 106 | function formatElapsedSeconds(ms: number | null): string | null { | ||
| 107 | if (ms === null) return null; | ||
| 108 | const elapsed = Math.max(0, Math.floor((Date.now() - ms) / 1000)); | ||
| 109 | if (elapsed < 2) return null; | ||
| 110 | return `Running for ${elapsed}s`; | ||
| 111 | } | ||
| 112 | |||
| 113 | export function useGitActionProgress(target: VcsActionTarget): GitActionProgress { | ||
| 114 | const actionState = useVcsActionState(target); | ||
| 115 | const { result } = useGitActionResultNotification(); | ||
| 116 | |||
| 117 | const [, forceUpdate] = useState(0); | ||
| 118 | const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); | ||
| 119 | |||
| 120 | const startElapsedTimer = useCallback(() => { | ||
| 121 | if (intervalRef.current) return; | ||
| 122 | intervalRef.current = setInterval(() => forceUpdate((n) => n + 1), 1000); | ||
| 123 | }, []); | ||
| 124 | |||
| 125 | const stopElapsedTimer = useCallback(() => { | ||
| 126 | if (intervalRef.current) { | ||
| 127 | clearInterval(intervalRef.current); | ||
| 128 | intervalRef.current = null; | ||
| 129 | } | ||
| 130 | }, []); | ||
| 131 | |||
| 132 | useEffect(() => { | ||
| 133 | if (actionState.isRunning) { | ||
| 134 | startElapsedTimer(); | ||
| 135 | } else { | ||
| 136 | stopElapsedTimer(); | ||
| 137 | } | ||
| 138 | return stopElapsedTimer; | ||
| 139 | }, [actionState.isRunning, startElapsedTimer, stopElapsedTimer]); | ||
| 140 | |||
| 141 | if (actionState.isRunning) { | ||
| 142 | const description = | ||
| 143 | actionState.lastOutputLine ?? | ||
| 144 | formatElapsedSeconds(actionState.hookStartedAtMs ?? actionState.phaseStartedAtMs); | ||
| 145 | return { | ||
| 146 | phase: "running", | ||
| 147 | label: actionState.currentLabel, | ||
| 148 | description, | ||
| 149 | }; | ||
| 150 | } | ||
| 151 | |||
| 152 | if (result) { | ||
| 153 | return { | ||
| 154 | phase: result.type, | ||
| 155 | label: result.title, | ||
| 156 | description: result.description ?? null, | ||
| 157 | prUrl: result.prUrl, | ||
| 158 | }; | ||
| 159 | } | ||
| 160 | |||
| 161 | return EMPTY_PROGRESS; | ||
| 162 | } | ||
apps/mobile/src/state/use-vcs-refs.ts created+51| ... | @@ -0,0 +1,51 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useEffect, useMemo } from "react"; | ||
| 3 | import { | ||
| 4 | type VcsRefState, | ||
| 5 | type VcsRefTarget, | ||
| 6 | EMPTY_VCS_REF_ATOM, | ||
| 7 | EMPTY_VCS_REF_STATE, | ||
| 8 | createVcsRefManager, | ||
| 9 | getVcsRefTargetKey, | ||
| 10 | vcsRefStateAtom, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | |||
| 13 | import { appAtomRegistry } from "./atom-registry"; | ||
| 14 | import { | ||
| 15 | getEnvironmentClient, | ||
| 16 | subscribeEnvironmentConnections, | ||
| 17 | } from "./environment-session-registry"; | ||
| 18 | |||
| 19 | const VCS_REF_LIST_LIMIT = 100; | ||
| 20 | const VCS_REF_STALE_TIME_MS = 5_000; | ||
| 21 | |||
| 22 | export const vcsRefManager = createVcsRefManager({ | ||
| 23 | getRegistry: () => appAtomRegistry, | ||
| 24 | getClient: (environmentId) => { | ||
| 25 | const client = getEnvironmentClient(environmentId); | ||
| 26 | return client ? client.vcs : null; | ||
| 27 | }, | ||
| 28 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 29 | watchLimit: VCS_REF_LIST_LIMIT, | ||
| 30 | staleTimeMs: VCS_REF_STALE_TIME_MS, | ||
| 31 | onBackgroundError: (error) => { | ||
| 32 | console.warn("[vcs-refs] background refresh failed", error); | ||
| 33 | }, | ||
| 34 | }); | ||
| 35 | |||
| 36 | export function useVcsRefs(target: VcsRefTarget): VcsRefState { | ||
| 37 | const stableTarget = useMemo( | ||
| 38 | () => ({ | ||
| 39 | environmentId: target.environmentId, | ||
| 40 | cwd: target.cwd, | ||
| 41 | query: target.query ?? null, | ||
| 42 | }), | ||
| 43 | [target.cwd, target.environmentId, target.query], | ||
| 44 | ); | ||
| 45 | const targetKey = getVcsRefTargetKey(stableTarget); | ||
| 46 | |||
| 47 | useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); | ||
| 48 | |||
| 49 | const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); | ||
| 50 | return targetKey === null ? EMPTY_VCS_REF_STATE : state; | ||
| 51 | } | ||
apps/mobile/src/state/use-vcs-status.ts created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type VcsStatusState, | ||
| 4 | type VcsStatusTarget, | ||
| 5 | EMPTY_VCS_STATUS_ATOM, | ||
| 6 | EMPTY_VCS_STATUS_STATE, | ||
| 7 | createVcsStatusManager, | ||
| 8 | getVcsStatusTargetKey, | ||
| 9 | vcsStatusStateAtom, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { useEffect } from "react"; | ||
| 12 | |||
| 13 | import { appAtomRegistry } from "./atom-registry"; | ||
| 14 | import { | ||
| 15 | getEnvironmentClient, | ||
| 16 | subscribeEnvironmentConnections, | ||
| 17 | } from "./environment-session-registry"; | ||
| 18 | |||
| 19 | /** | ||
| 20 | * Singleton VCS status manager for the mobile app. | ||
| 21 | * | ||
| 22 | * Uses ref-counted `onStatus` subscriptions (one per unique cwd) | ||
| 23 | * rather than one-shot `refreshStatus` RPCs. Multiple threads | ||
| 24 | * sharing the same cwd (i.e. same project, no worktree) share | ||
| 25 | * a single WS subscription. | ||
| 26 | * | ||
| 27 | * `subscribeClientChanges` ensures subscriptions are established | ||
| 28 | * even when the WS connection isn't ready at mount time, and | ||
| 29 | * re-established on reconnection. | ||
| 30 | */ | ||
| 31 | export const vcsStatusManager = createVcsStatusManager({ | ||
| 32 | getRegistry: () => appAtomRegistry, | ||
| 33 | getClient: (environmentId) => { | ||
| 34 | const client = getEnvironmentClient(environmentId); | ||
| 35 | return client ? client.vcs : null; | ||
| 36 | }, | ||
| 37 | getClientIdentity: (environmentId) => { | ||
| 38 | return getEnvironmentClient(environmentId) ? environmentId : null; | ||
| 39 | }, | ||
| 40 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 41 | }); | ||
| 42 | |||
| 43 | /** | ||
| 44 | * Subscribe to live VCS status for a target (environmentId + cwd). | ||
| 45 | * | ||
| 46 | * Mirrors the web's `useVcsStatus` hook. Automatically subscribes | ||
| 47 | * on mount, ref-counts shared cwds, and unsubscribes on unmount. | ||
| 48 | * Returns reactive `VcsStatusState` via Effect atoms. | ||
| 49 | */ | ||
| 50 | export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { | ||
| 51 | const targetKey = getVcsStatusTargetKey(target); | ||
| 52 | |||
| 53 | useEffect( | ||
| 54 | () => vcsStatusManager.watch({ environmentId: target.environmentId, cwd: target.cwd }), | ||
| 55 | [target.environmentId, target.cwd], | ||
| 56 | ); | ||
| 57 | |||
| 58 | const state = useAtomValue( | ||
| 59 | targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, | ||
| 60 | ); | ||
| 61 | return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; | ||
| 62 | } | ||
apps/mobile/tsconfig.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "extends": "expo/tsconfig.base", | ||
| 3 | "compilerOptions": { | ||
| 4 | "allowImportingTsExtensions": true, | ||
| 5 | "strict": true | ||
| 6 | } | ||
| 7 | } | ||
apps/mobile/uniwind-types.d.ts created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | // NOTE: This file is generated by uniwind and it should not be edited manually. | ||
| 2 | /// <reference types="uniwind/types" /> | ||
| 3 | |||
| 4 | declare module 'uniwind' { | ||
| 5 | export interface UniwindConfig { | ||
| 6 | themes: readonly ['light', 'dark'] | ||
| 7 | } | ||
| 8 | } | ||
| 9 | |||
| 10 | export {} | ||
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts+5| ... | @@ -54,12 +54,14 @@ describe("ProjectSetupScriptRunner", () => { | ... | @@ -54,12 +54,14 @@ describe("ProjectSetupScriptRunner", () => { |
| 54 | Layer.provideMerge( | 54 | Layer.provideMerge( |
| 55 | Layer.succeed(TerminalManager, { | 55 | Layer.succeed(TerminalManager, { |
| 56 | open, | 56 | open, |
| 57 | attachStream: () => Effect.die(new Error("unused")), | ||
| 57 | write, | 58 | write, |
| 58 | resize: () => Effect.void, | 59 | resize: () => Effect.void, |
| 59 | clear: () => Effect.void, | 60 | clear: () => Effect.void, |
| 60 | restart: () => Effect.die(new Error("unused")), | 61 | restart: () => Effect.die(new Error("unused")), |
| 61 | close: () => Effect.void, | 62 | close: () => Effect.void, |
| 62 | subscribe: () => Effect.succeed(() => undefined), | 63 | subscribe: () => Effect.succeed(() => undefined), |
| 64 | subscribeMetadata: () => Effect.succeed(() => undefined), | ||
| 63 | }), | 65 | }), |
| 64 | ), | 66 | ), |
| 65 | ), | 67 | ), |
| ... | @@ -92,6 +94,7 @@ describe("ProjectSetupScriptRunner", () => { | ... | @@ -92,6 +94,7 @@ describe("ProjectSetupScriptRunner", () => { |
| 92 | history: "", | 94 | history: "", |
| 93 | exitCode: null, | 95 | exitCode: null, |
| 94 | exitSignal: null, | 96 | exitSignal: null, |
| 97 | label: "setup-setup", | ||
| 95 | updatedAt: "2026-01-01T00:00:00.000Z", | 98 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 96 | }), | 99 | }), |
| 97 | ); | 100 | ); |
| ... | @@ -113,12 +116,14 @@ describe("ProjectSetupScriptRunner", () => { | ... | @@ -113,12 +116,14 @@ describe("ProjectSetupScriptRunner", () => { |
| 113 | Layer.provideMerge( | 116 | Layer.provideMerge( |
| 114 | Layer.succeed(TerminalManager, { | 117 | Layer.succeed(TerminalManager, { |
| 115 | open, | 118 | open, |
| 119 | attachStream: () => Effect.die(new Error("unused")), | ||
| 116 | write, | 120 | write, |
| 117 | resize: () => Effect.void, | 121 | resize: () => Effect.void, |
| 118 | clear: () => Effect.void, | 122 | clear: () => Effect.void, |
| 119 | restart: () => Effect.die(new Error("unused")), | 123 | restart: () => Effect.die(new Error("unused")), |
| 120 | close: () => Effect.void, | 124 | close: () => Effect.void, |
| 121 | subscribe: () => Effect.succeed(() => undefined), | 125 | subscribe: () => Effect.succeed(() => undefined), |
| 126 | subscribeMetadata: () => Effect.succeed(() => undefined), | ||
| 122 | }), | 127 | }), |
| 123 | ), | 128 | ), |
| 124 | ), | 129 | ), |
apps/server/src/review/ReviewService.test.ts created+76| ... | @@ -0,0 +1,76 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | |||
| 7 | import { ServerConfig } from "../config.ts"; | ||
| 8 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 9 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 10 | import * as ReviewService from "./ReviewService.ts"; | ||
| 11 | |||
| 12 | function makeLayer(input: { | ||
| 13 | readonly workspaceRoot: string; | ||
| 14 | readonly baseDir: string; | ||
| 15 | readonly detectCalls?: Array<{ readonly cwd: string }>; | ||
| 16 | }) { | ||
| 17 | return ReviewService.layer.pipe( | ||
| 18 | Layer.provide( | ||
| 19 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ | ||
| 20 | get: () => Effect.die("unexpected VCS registry get"), | ||
| 21 | resolve: () => Effect.die("unexpected VCS registry resolve"), | ||
| 22 | detect: (request) => | ||
| 23 | Effect.sync(() => { | ||
| 24 | input.detectCalls?.push({ cwd: request.cwd }); | ||
| 25 | return null; | ||
| 26 | }), | ||
| 27 | }), | ||
| 28 | ), | ||
| 29 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), | ||
| 30 | Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)), | ||
| 31 | Layer.provideMerge(NodeServices.layer), | ||
| 32 | ); | ||
| 33 | } | ||
| 34 | |||
| 35 | describe("ReviewService", () => { | ||
| 36 | it.effect("rejects diff preview cwd outside the configured workspace roots", () => | ||
| 37 | Effect.gen(function* () { | ||
| 38 | const fs = yield* FileSystem.FileSystem; | ||
| 39 | const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); | ||
| 40 | const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" }); | ||
| 41 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); | ||
| 42 | const detectCalls: Array<{ readonly cwd: string }> = []; | ||
| 43 | |||
| 44 | const error = yield* Effect.gen(function* () { | ||
| 45 | const review = yield* ReviewService.ReviewService; | ||
| 46 | return yield* review.getDiffPreview({ cwd: outsideRoot }).pipe(Effect.flip); | ||
| 47 | }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls }))); | ||
| 48 | |||
| 49 | assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); | ||
| 50 | assert.strictEqual(error.operation, "ReviewService.getDiffPreview"); | ||
| 51 | assert.match( | ||
| 52 | "detail" in error ? error.detail : "", | ||
| 53 | /must stay within the configured workspace root/, | ||
| 54 | ); | ||
| 55 | assert.deepStrictEqual(detectCalls, []); | ||
| 56 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 57 | ); | ||
| 58 | |||
| 59 | it.effect("allows diff preview cwd inside the configured workspace root", () => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const fs = yield* FileSystem.FileSystem; | ||
| 62 | const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); | ||
| 63 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); | ||
| 64 | const detectCalls: Array<{ readonly cwd: string }> = []; | ||
| 65 | |||
| 66 | const result = yield* Effect.gen(function* () { | ||
| 67 | const review = yield* ReviewService.ReviewService; | ||
| 68 | return yield* review.getDiffPreview({ cwd: workspaceRoot }); | ||
| 69 | }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls }))); | ||
| 70 | |||
| 71 | assert.strictEqual(result.cwd, workspaceRoot); | ||
| 72 | assert.deepStrictEqual(result.sources, []); | ||
| 73 | assert.deepStrictEqual(detectCalls, [{ cwd: workspaceRoot }]); | ||
| 74 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 75 | ); | ||
| 76 | }); | ||
apps/server/src/review/ReviewService.ts created+101| ... | @@ -0,0 +1,101 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Path from "effect/Path"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | VcsRepositoryDetectionError, | ||
| 10 | VcsUnsupportedOperationError, | ||
| 11 | type ReviewDiffPreviewError, | ||
| 12 | type ReviewDiffPreviewInput, | ||
| 13 | type ReviewDiffPreviewResult, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | import { ServerConfig } from "../config.ts"; | ||
| 17 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 18 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 19 | |||
| 20 | export interface ReviewServiceShape { | ||
| 21 | readonly getDiffPreview: ( | ||
| 22 | input: ReviewDiffPreviewInput, | ||
| 23 | ) => Effect.Effect<ReviewDiffPreviewResult, ReviewDiffPreviewError>; | ||
| 24 | } | ||
| 25 | |||
| 26 | export class ReviewService extends Context.Service<ReviewService, ReviewServiceShape>()( | ||
| 27 | "t3/review/ReviewService", | ||
| 28 | ) {} | ||
| 29 | |||
| 30 | export const make = Effect.fn("makeReviewService")(function* () { | ||
| 31 | const config = yield* ServerConfig; | ||
| 32 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 33 | const path = yield* Path.Path; | ||
| 34 | const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; | ||
| 35 | const git = yield* GitVcsDriver.GitVcsDriver; | ||
| 36 | |||
| 37 | const canonicalizePath = (value: string) => | ||
| 38 | fileSystem | ||
| 39 | .realPath(path.resolve(value)) | ||
| 40 | .pipe(Effect.catch(() => Effect.succeed(path.resolve(value)))); | ||
| 41 | |||
| 42 | const isWithinRoot = (candidate: string, root: string) => { | ||
| 43 | const relative = path.relative(root, candidate); | ||
| 44 | return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); | ||
| 45 | }; | ||
| 46 | |||
| 47 | const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* ( | ||
| 48 | cwd: string, | ||
| 49 | ) { | ||
| 50 | const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([ | ||
| 51 | canonicalizePath(cwd), | ||
| 52 | canonicalizePath(config.cwd), | ||
| 53 | canonicalizePath(config.worktreesDir), | ||
| 54 | ]); | ||
| 55 | |||
| 56 | if (isWithinRoot(candidate, workspaceRoot) || isWithinRoot(candidate, worktreesRoot)) { | ||
| 57 | return; | ||
| 58 | } | ||
| 59 | |||
| 60 | return yield* new VcsRepositoryDetectionError({ | ||
| 61 | operation: "ReviewService.getDiffPreview", | ||
| 62 | cwd, | ||
| 63 | detail: "Review diff preview cwd must stay within the configured workspace root.", | ||
| 64 | }); | ||
| 65 | }); | ||
| 66 | |||
| 67 | const getDiffPreview: ReviewServiceShape["getDiffPreview"] = Effect.fn( | ||
| 68 | "ReviewService.getDiffPreview", | ||
| 69 | )(function* (input) { | ||
| 70 | yield* assertWorkspaceBoundCwd(input.cwd); | ||
| 71 | |||
| 72 | const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" }); | ||
| 73 | if (!handle) { | ||
| 74 | return { | ||
| 75 | cwd: input.cwd, | ||
| 76 | generatedAt: yield* DateTime.now, | ||
| 77 | sources: [], | ||
| 78 | }; | ||
| 79 | } | ||
| 80 | |||
| 81 | const getDriverDiffPreview = handle.driver.getDiffPreview; | ||
| 82 | if (!getDriverDiffPreview) { | ||
| 83 | if (handle.kind === "git") { | ||
| 84 | return yield* git.getReviewDiffPreview(input); | ||
| 85 | } | ||
| 86 | return yield* new VcsUnsupportedOperationError({ | ||
| 87 | operation: "ReviewService.getDiffPreview", | ||
| 88 | kind: handle.kind, | ||
| 89 | detail: `The ${handle.kind} VCS driver does not support review diff previews.`, | ||
| 90 | }); | ||
| 91 | } | ||
| 92 | |||
| 93 | return yield* getDriverDiffPreview(input); | ||
| 94 | }); | ||
| 95 | |||
| 96 | return ReviewService.of({ | ||
| 97 | getDiffPreview, | ||
| 98 | }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | export const layer = Layer.effect(ReviewService, make()); | ||
apps/server/src/server.test.ts+68-2| ... | @@ -112,6 +112,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; | ... | @@ -112,6 +112,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 112 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; | 112 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; |
| 113 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; | 113 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 114 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | 114 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 115 | import * as ReviewService from "./review/ReviewService.ts"; | ||
| 115 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 116 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 116 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | 117 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; |
| 117 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | 118 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; |
| ... | @@ -327,6 +328,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -327,6 +328,7 @@ const buildAppUnderTest = (options?: { |
| 327 | gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>; | 328 | gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>; |
| 328 | gitManager?: Partial<GitManagerShape>; | 329 | gitManager?: Partial<GitManagerShape>; |
| 329 | sourceControlRepositoryService?: Partial<SourceControlRepositoryService.SourceControlRepositoryServiceShape>; | 330 | sourceControlRepositoryService?: Partial<SourceControlRepositoryService.SourceControlRepositoryServiceShape>; |
| 331 | reviewService?: Partial<ReviewService.ReviewServiceShape>; | ||
| 330 | vcsStatusBroadcaster?: Partial<VcsStatusBroadcaster.VcsStatusBroadcasterShape>; | 332 | vcsStatusBroadcaster?: Partial<VcsStatusBroadcaster.VcsStatusBroadcasterShape>; |
| 331 | projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>; | 333 | projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>; |
| 332 | terminalManager?: Partial<TerminalManagerShape>; | 334 | terminalManager?: Partial<TerminalManagerShape>; |
| ... | @@ -497,6 +499,14 @@ const buildAppUnderTest = (options?: { | ... | @@ -497,6 +499,14 @@ const buildAppUnderTest = (options?: { |
| 497 | const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( | 499 | const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( |
| 498 | Layer.provide(vcsDriverRegistryLayer), | 500 | Layer.provide(vcsDriverRegistryLayer), |
| 499 | ); | 501 | ); |
| 502 | const reviewLayer = options?.layers?.reviewService | ||
| 503 | ? Layer.mock(ReviewService.ReviewService)({ | ||
| 504 | ...options.layers.reviewService, | ||
| 505 | }) | ||
| 506 | : ReviewService.layer.pipe( | ||
| 507 | Layer.provideMerge(gitVcsDriverLayer), | ||
| 508 | Layer.provide(vcsDriverRegistryLayer), | ||
| 509 | ); | ||
| 500 | const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster | 510 | const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster |
| 501 | ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ | 511 | ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ |
| 502 | ...options.layers.vcsStatusBroadcaster, | 512 | ...options.layers.vcsStatusBroadcaster, |
| ... | @@ -611,6 +621,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -611,6 +621,7 @@ const buildAppUnderTest = (options?: { |
| 611 | Layer.provide(gitManagerLayer), | 621 | Layer.provide(gitManagerLayer), |
| 612 | Layer.provide(gitVcsDriverLayer), | 622 | Layer.provide(gitVcsDriverLayer), |
| 613 | Layer.provide(gitWorkflowLayer), | 623 | Layer.provide(gitWorkflowLayer), |
| 624 | Layer.provide(reviewLayer), | ||
| 614 | Layer.provide(vcsProvisioningLayer), | 625 | Layer.provide(vcsProvisioningLayer), |
| 615 | Layer.provide( | 626 | Layer.provide( |
| 616 | Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ | 627 | Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ |
| ... | @@ -2492,7 +2503,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2492,7 +2503,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2492 | it.effect("routes websocket rpc git methods", () => | 2503 | it.effect("routes websocket rpc git methods", () => |
| 2493 | Effect.gen(function* () { | 2504 | Effect.gen(function* () { |
| 2494 | yield* buildAppUnderTest({ | 2505 | yield* buildAppUnderTest({ |
| 2506 | config: { | ||
| 2507 | cwd: "/tmp/repo", | ||
| 2508 | }, | ||
| 2495 | layers: { | 2509 | layers: { |
| 2510 | vcsDriver: { | ||
| 2511 | isInsideWorkTree: () => Effect.succeed(true), | ||
| 2512 | }, | ||
| 2496 | gitManager: { | 2513 | gitManager: { |
| 2497 | invalidateLocalStatus: () => Effect.void, | 2514 | invalidateLocalStatus: () => Effect.void, |
| 2498 | invalidateRemoteStatus: () => Effect.void, | 2515 | invalidateRemoteStatus: () => Effect.void, |
| ... | @@ -2629,8 +2646,49 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2629,8 +2646,49 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2629 | createRef: (input) => Effect.succeed({ refName: input.refName }), | 2646 | createRef: (input) => Effect.succeed({ refName: input.refName }), |
| 2630 | switchRef: (input) => Effect.succeed({ refName: input.refName }), | 2647 | switchRef: (input) => Effect.succeed({ refName: input.refName }), |
| 2631 | }, | 2648 | }, |
| 2632 | vcsDriver: { | 2649 | vcsStatusBroadcaster: { |
| 2633 | isInsideWorkTree: () => Effect.succeed(true), | 2650 | refreshStatus: () => |
| 2651 | Effect.succeed({ | ||
| 2652 | isRepo: true, | ||
| 2653 | hasPrimaryRemote: true, | ||
| 2654 | isDefaultRef: true, | ||
| 2655 | refName: "main", | ||
| 2656 | hasWorkingTreeChanges: false, | ||
| 2657 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 2658 | hasUpstream: true, | ||
| 2659 | aheadCount: 0, | ||
| 2660 | behindCount: 0, | ||
| 2661 | pr: null, | ||
| 2662 | }), | ||
| 2663 | }, | ||
| 2664 | reviewService: { | ||
| 2665 | getDiffPreview: (input) => | ||
| 2666 | Effect.succeed({ | ||
| 2667 | cwd: input.cwd, | ||
| 2668 | generatedAt: DateTime.nowUnsafe(), | ||
| 2669 | sources: [ | ||
| 2670 | { | ||
| 2671 | id: "working-tree", | ||
| 2672 | kind: "working-tree", | ||
| 2673 | title: "Dirty worktree", | ||
| 2674 | baseRef: "HEAD", | ||
| 2675 | headRef: null, | ||
| 2676 | diff: "dirty-diff", | ||
| 2677 | diffHash: "hash-dirty", | ||
| 2678 | truncated: false, | ||
| 2679 | }, | ||
| 2680 | { | ||
| 2681 | id: "branch-range", | ||
| 2682 | kind: "branch-range", | ||
| 2683 | title: "Against main", | ||
| 2684 | baseRef: "main", | ||
| 2685 | headRef: "feature/demo", | ||
| 2686 | diff: "base-diff", | ||
| 2687 | diffHash: "hash-base", | ||
| 2688 | truncated: false, | ||
| 2689 | }, | ||
| 2690 | ], | ||
| 2691 | }), | ||
| 2634 | }, | 2692 | }, |
| 2635 | }, | 2693 | }, |
| 2636 | }); | 2694 | }); |
| ... | @@ -2738,6 +2796,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2738,6 +2796,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2738 | }), | 2796 | }), |
| 2739 | ), | 2797 | ), |
| 2740 | ); | 2798 | ); |
| 2799 | |||
| 2800 | const diffPreview = yield* Effect.scoped( | ||
| 2801 | withWsRpcClient(wsUrl, (client) => | ||
| 2802 | client[WS_METHODS.reviewGetDiffPreview]({ cwd: "/tmp/repo" }), | ||
| 2803 | ), | ||
| 2804 | ); | ||
| 2805 | assert.equal(diffPreview.sources[0]?.diff, "dirty-diff"); | ||
| 2741 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 2806 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 2742 | ); | 2807 | ); |
| 2743 | 2808 | ||
| ... | @@ -4177,6 +4242,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4177,6 +4242,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4177 | history: "", | 4242 | history: "", |
| 4178 | exitCode: null, | 4243 | exitCode: null, |
| 4179 | exitSignal: null, | 4244 | exitSignal: null, |
| 4245 | label: "Primary", | ||
| 4180 | updatedAt: "2026-01-01T00:00:00.000Z", | 4246 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 4181 | }; | 4247 | }; |
| 4182 | 4248 |
apps/server/src/server.ts+7| ... | @@ -56,6 +56,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; | ... | @@ -56,6 +56,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; |
| 56 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; | 56 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 57 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; | 57 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 58 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | 58 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 59 | import * as ReviewService from "./review/ReviewService.ts"; | ||
| 59 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | 60 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| 60 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 61 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 61 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; | 62 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; |
| ... | @@ -199,11 +200,17 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l | ... | @@ -199,11 +200,17 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l |
| 199 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), | 200 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), |
| 200 | ); | 201 | ); |
| 201 | 202 | ||
| 203 | const ReviewLayerLive = ReviewService.layer.pipe( | ||
| 204 | Layer.provideMerge(GitVcsDriver.layer), | ||
| 205 | Layer.provideMerge(VcsDriverRegistryLayerLive), | ||
| 206 | ); | ||
| 207 | |||
| 202 | const VcsLayerLive = Layer.empty.pipe( | 208 | const VcsLayerLive = Layer.empty.pipe( |
| 203 | Layer.provideMerge(VcsProjectConfig.layer), | 209 | Layer.provideMerge(VcsProjectConfig.layer), |
| 204 | Layer.provideMerge(VcsDriverRegistryLayerLive), | 210 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
| 205 | Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), | 211 | Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), |
| 206 | Layer.provideMerge(GitWorkflowLayerLive), | 212 | Layer.provideMerge(GitWorkflowLayerLive), |
| 213 | Layer.provideMerge(ReviewLayerLive), | ||
| 207 | Layer.provideMerge(SourceControlRepositoryServiceLayerLive), | 214 | Layer.provideMerge(SourceControlRepositoryServiceLayerLive), |
| 208 | Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), | 215 | Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), |
| 209 | ); | 216 | ); |
apps/server/src/terminal/Layers/Manager.test.ts+348-20| ... | @@ -2,7 +2,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -2,7 +2,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import { | 3 | import { |
| 4 | DEFAULT_TERMINAL_ID, | 4 | DEFAULT_TERMINAL_ID, |
| 5 | type TerminalAttachStreamEvent, | ||
| 5 | type TerminalEvent, | 6 | type TerminalEvent, |
| 7 | type TerminalMetadataStreamEvent, | ||
| 6 | type TerminalOpenInput, | 8 | type TerminalOpenInput, |
| 7 | type TerminalRestartInput, | 9 | type TerminalRestartInput, |
| 8 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| ... | @@ -199,7 +201,10 @@ interface CreateManagerOptions { | ... | @@ -199,7 +201,10 @@ interface CreateManagerOptions { |
| 199 | shellResolver?: () => string; | 201 | shellResolver?: () => string; |
| 200 | platform?: NodeJS.Platform; | 202 | platform?: NodeJS.Platform; |
| 201 | env?: NodeJS.ProcessEnv; | 203 | env?: NodeJS.ProcessEnv; |
| 202 | subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>; | 204 | subprocessInspector?: (terminalPid: number) => Effect.Effect<{ |
| 205 | readonly hasRunningSubprocess: boolean; | ||
| 206 | readonly childCommand: string | null; | ||
| 207 | }>; | ||
| 203 | subprocessPollIntervalMs?: number; | 208 | subprocessPollIntervalMs?: number; |
| 204 | processKillGraceMs?: number; | 209 | processKillGraceMs?: number; |
| 205 | maxRetainedInactiveSessions?: number; | 210 | maxRetainedInactiveSessions?: number; |
| ... | @@ -236,8 +241,8 @@ const createManager = ( | ... | @@ -236,8 +241,8 @@ const createManager = ( |
| 236 | ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), | 241 | ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), |
| 237 | ...(options.platform !== undefined ? { platform: options.platform } : {}), | 242 | ...(options.platform !== undefined ? { platform: options.platform } : {}), |
| 238 | ...(options.env !== undefined ? { env: options.env } : {}), | 243 | ...(options.env !== undefined ? { env: options.env } : {}), |
| 239 | ...(options.subprocessChecker !== undefined | 244 | ...(options.subprocessInspector !== undefined |
| 240 | ? { subprocessChecker: options.subprocessChecker } | 245 | ? { subprocessInspector: options.subprocessInspector } |
| 241 | : {}), | 246 | : {}), |
| 242 | ...(options.subprocessPollIntervalMs !== undefined | 247 | ...(options.subprocessPollIntervalMs !== undefined |
| 243 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } | 248 | ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } |
| ... | @@ -248,11 +253,10 @@ const createManager = ( | ... | @@ -248,11 +253,10 @@ const createManager = ( |
| 248 | : {}), | 253 | : {}), |
| 249 | }); | 254 | }); |
| 250 | const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); | 255 | const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); |
| 251 | const scope = yield* Effect.scope; | ||
| 252 | const unsubscribe = yield* manager.subscribe((event) => | 256 | const unsubscribe = yield* manager.subscribe((event) => |
| 253 | Ref.update(eventsRef, (events) => [...events, event]), | 257 | Ref.update(eventsRef, (events) => [...events, event]), |
| 254 | ); | 258 | ); |
| 255 | yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); | 259 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); |
| 256 | 260 | ||
| 257 | return { | 261 | return { |
| 258 | baseDir, | 262 | baseDir, |
| ... | @@ -281,13 +285,116 @@ it.layer( | ... | @@ -281,13 +285,116 @@ it.layer( |
| 281 | const third = yield* manager.open(openInput()); | 285 | const third = yield* manager.open(openInput()); |
| 282 | 286 | ||
| 283 | assert.equal(first.threadId, "thread-1"); | 287 | assert.equal(first.threadId, "thread-1"); |
| 284 | assert.equal(first.terminalId, "default"); | 288 | assert.equal(first.terminalId, DEFAULT_TERMINAL_ID); |
| 285 | assert.equal(second.threadId, "thread-1"); | 289 | assert.equal(second.threadId, "thread-1"); |
| 286 | assert.equal(third.threadId, "thread-1"); | 290 | assert.equal(third.threadId, "thread-1"); |
| 287 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | 291 | expect(ptyAdapter.spawnInputs).toHaveLength(1); |
| 288 | }), | 292 | }), |
| 289 | ); | 293 | ); |
| 290 | 294 | ||
| 295 | it.effect("attaches to running sessions without restarting them", () => | ||
| 296 | Effect.gen(function* () { | ||
| 297 | const { manager, ptyAdapter } = yield* createManager(); | ||
| 298 | |||
| 299 | yield* manager.open(openInput()); | ||
| 300 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 301 | const unsubscribe = yield* manager.attachStream( | ||
| 302 | { | ||
| 303 | threadId: "thread-1", | ||
| 304 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 305 | cols: 100, | ||
| 306 | rows: 40, | ||
| 307 | }, | ||
| 308 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 309 | ); | ||
| 310 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 311 | |||
| 312 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 313 | expect(snapshot).toBeDefined(); | ||
| 314 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 315 | assert.equal(snapshot.snapshot.threadId, "thread-1"); | ||
| 316 | assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID); | ||
| 317 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 318 | }), | ||
| 319 | ); | ||
| 320 | |||
| 321 | it.effect("attaches to exited sessions without restarting them", () => | ||
| 322 | Effect.gen(function* () { | ||
| 323 | const { manager, ptyAdapter, getEvents } = yield* createManager(); | ||
| 324 | |||
| 325 | yield* manager.open(openInput()); | ||
| 326 | const process = ptyAdapter.processes[0]; | ||
| 327 | expect(process).toBeDefined(); | ||
| 328 | if (!process) return; | ||
| 329 | |||
| 330 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 331 | |||
| 332 | yield* waitFor( | ||
| 333 | Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), | ||
| 334 | "1200 millis", | ||
| 335 | ); | ||
| 336 | |||
| 337 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 338 | const unsubscribe = yield* manager.attachStream( | ||
| 339 | openInput({ | ||
| 340 | env: { | ||
| 341 | T3CODE_WORKTREE_PATH: "/tmp/should-not-restart", | ||
| 342 | }, | ||
| 343 | worktreePath: "/tmp/should-not-restart", | ||
| 344 | }), | ||
| 345 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 346 | ); | ||
| 347 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 348 | |||
| 349 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 350 | expect(snapshot).toBeDefined(); | ||
| 351 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 352 | assert.equal(snapshot.snapshot.status, "exited"); | ||
| 353 | assert.equal(snapshot.snapshot.worktreePath, null); | ||
| 354 | expect(ptyAdapter.spawnInputs).toHaveLength(1); | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | it.effect("restarts inactive sessions from attach only when requested", () => | ||
| 359 | Effect.gen(function* () { | ||
| 360 | const { manager, ptyAdapter, getEvents } = yield* createManager(); | ||
| 361 | |||
| 362 | yield* manager.open(openInput()); | ||
| 363 | const process = ptyAdapter.processes[0]; | ||
| 364 | expect(process).toBeDefined(); | ||
| 365 | if (!process) return; | ||
| 366 | |||
| 367 | process.emitExit({ exitCode: 0, signal: 0 }); | ||
| 368 | |||
| 369 | yield* waitFor( | ||
| 370 | Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), | ||
| 371 | "1200 millis", | ||
| 372 | ); | ||
| 373 | |||
| 374 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 375 | const unsubscribe = yield* manager.attachStream( | ||
| 376 | { | ||
| 377 | ...openInput({ | ||
| 378 | env: { | ||
| 379 | T3CODE_WORKTREE_PATH: "/tmp/restart-requested", | ||
| 380 | }, | ||
| 381 | worktreePath: "/tmp/restart-requested", | ||
| 382 | }), | ||
| 383 | restartIfNotRunning: true, | ||
| 384 | }, | ||
| 385 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 386 | ); | ||
| 387 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 388 | |||
| 389 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 390 | expect(snapshot).toBeDefined(); | ||
| 391 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 392 | assert.equal(snapshot.snapshot.status, "running"); | ||
| 393 | assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested"); | ||
| 394 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 395 | }), | ||
| 396 | ); | ||
| 397 | |||
| 291 | const makeDirectory = (filePath: string) => | 398 | const makeDirectory = (filePath: string) => |
| 292 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => | 399 | Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => |
| 293 | fs.makeDirectory(filePath, { recursive: true }), | 400 | fs.makeDirectory(filePath, { recursive: true }), |
| ... | @@ -436,7 +543,7 @@ it.layer( | ... | @@ -436,7 +543,7 @@ it.layer( |
| 436 | (event) => | 543 | (event) => |
| 437 | event.type === "cleared" && | 544 | event.type === "cleared" && |
| 438 | event.threadId === "thread-1" && | 545 | event.threadId === "thread-1" && |
| 439 | event.terminalId === "default", | 546 | event.terminalId === DEFAULT_TERMINAL_ID, |
| 440 | ), | 547 | ), |
| 441 | ).toBe(true); | 548 | ).toBe(true); |
| 442 | }), | 549 | }), |
| ... | @@ -472,6 +579,34 @@ it.layer( | ... | @@ -472,6 +579,34 @@ it.layer( |
| 472 | }), | 579 | }), |
| 473 | ); | 580 | ); |
| 474 | 581 | ||
| 582 | it.effect("restarts a running session when open is called with a different cwd", () => | ||
| 583 | Effect.gen(function* () { | ||
| 584 | const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager(); | ||
| 585 | const path = yield* Path.Path; | ||
| 586 | const originalCwd = path.join(baseDir, "original"); | ||
| 587 | const differentCwd = path.join(baseDir, "different"); | ||
| 588 | yield* makeDirectory(originalCwd); | ||
| 589 | yield* makeDirectory(differentCwd); | ||
| 590 | |||
| 591 | yield* manager.open(openInput({ cwd: originalCwd })); | ||
| 592 | const firstProcess = ptyAdapter.processes[0]; | ||
| 593 | expect(firstProcess).toBeDefined(); | ||
| 594 | if (!firstProcess) return; | ||
| 595 | |||
| 596 | firstProcess.emitData("before reopen\n"); | ||
| 597 | const logPath = yield* historyLogPath(logsDir); | ||
| 598 | yield* waitFor(pathExists(logPath)); | ||
| 599 | |||
| 600 | const reopened = yield* manager.open(openInput({ cwd: differentCwd })); | ||
| 601 | |||
| 602 | expect(ptyAdapter.spawnInputs).toHaveLength(2); | ||
| 603 | assert.equal(firstProcess.killed, true); | ||
| 604 | assert.equal(reopened.cwd, differentCwd); | ||
| 605 | assert.equal(reopened.history, ""); | ||
| 606 | yield* waitFor(Effect.map(readFileString(logPath), (text) => text === "")); | ||
| 607 | }), | ||
| 608 | ); | ||
| 609 | |||
| 475 | it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => | 610 | it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => |
| 476 | Effect.gen(function* () { | 611 | Effect.gen(function* () { |
| 477 | const { manager, getEvents, baseDir } = yield* createManager(); | 612 | const { manager, getEvents, baseDir } = yield* createManager(); |
| ... | @@ -607,27 +742,40 @@ it.layer( | ... | @@ -607,27 +742,40 @@ it.layer( |
| 607 | 742 | ||
| 608 | it.effect("emits subprocess activity events when child-process state changes", () => | 743 | it.effect("emits subprocess activity events when child-process state changes", () => |
| 609 | Effect.gen(function* () { | 744 | Effect.gen(function* () { |
| 610 | let hasRunningSubprocess = false; | 745 | let inspect: { |
| 746 | readonly hasRunningSubprocess: boolean; | ||
| 747 | readonly childCommand: string | null; | ||
| 748 | } = { hasRunningSubprocess: false, childCommand: null }; | ||
| 611 | const { manager, getEvents } = yield* createManager(5, { | 749 | const { manager, getEvents } = yield* createManager(5, { |
| 612 | subprocessChecker: () => Effect.succeed(hasRunningSubprocess), | 750 | subprocessInspector: () => Effect.succeed(inspect), |
| 613 | subprocessPollIntervalMs: 20, | 751 | subprocessPollIntervalMs: 20, |
| 614 | }); | 752 | }); |
| 615 | 753 | ||
| 616 | yield* manager.open(openInput()); | 754 | yield* manager.open(openInput()); |
| 617 | expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); | 755 | expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); |
| 618 | 756 | ||
| 619 | hasRunningSubprocess = true; | 757 | inspect = { hasRunningSubprocess: true, childCommand: "vim" }; |
| 620 | yield* waitFor( | 758 | yield* waitFor( |
| 621 | Effect.map(getEvents, (events) => | 759 | Effect.map(getEvents, (events) => |
| 622 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), | 760 | events.some( |
| 761 | (event) => | ||
| 762 | event.type === "activity" && | ||
| 763 | event.hasRunningSubprocess === true && | ||
| 764 | event.label === "vim", | ||
| 765 | ), | ||
| 623 | ), | 766 | ), |
| 624 | "1200 millis", | 767 | "1200 millis", |
| 625 | ); | 768 | ); |
| 626 | 769 | ||
| 627 | hasRunningSubprocess = false; | 770 | inspect = { hasRunningSubprocess: false, childCommand: null }; |
| 628 | yield* waitFor( | 771 | yield* waitFor( |
| 629 | Effect.map(getEvents, (events) => | 772 | Effect.map(getEvents, (events) => |
| 630 | events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), | 773 | events.some( |
| 774 | (event) => | ||
| 775 | event.type === "activity" && | ||
| 776 | event.hasRunningSubprocess === false && | ||
| 777 | event.label === "Terminal 1", | ||
| 778 | ), | ||
| 631 | ), | 779 | ), |
| 632 | "1200 millis", | 780 | "1200 millis", |
| 633 | ); | 781 | ); |
| ... | @@ -638,9 +786,9 @@ it.layer( | ... | @@ -638,9 +786,9 @@ it.layer( |
| 638 | Effect.gen(function* () { | 786 | Effect.gen(function* () { |
| 639 | let checks = 0; | 787 | let checks = 0; |
| 640 | const { manager } = yield* createManager(5, { | 788 | const { manager } = yield* createManager(5, { |
| 641 | subprocessChecker: () => { | 789 | subprocessInspector: () => { |
| 642 | checks += 1; | 790 | checks += 1; |
| 643 | return Effect.succeed(false); | 791 | return Effect.succeed({ hasRunningSubprocess: false, childCommand: null }); |
| 644 | }, | 792 | }, |
| 645 | subprocessPollIntervalMs: 20, | 793 | subprocessPollIntervalMs: 20, |
| 646 | }); | 794 | }); |
| ... | @@ -851,6 +999,21 @@ it.layer( | ... | @@ -851,6 +999,21 @@ it.layer( |
| 851 | }).pipe(Effect.provide(TestClock.layer())), | 999 | }).pipe(Effect.provide(TestClock.layer())), |
| 852 | ); | 1000 | ); |
| 853 | 1001 | ||
| 1002 | it.effect("publishes closed events when terminals are explicitly closed", () => | ||
| 1003 | Effect.gen(function* () { | ||
| 1004 | const { manager, getEvents } = yield* createManager(); | ||
| 1005 | yield* manager.open(openInput({ terminalId: "default" })); | ||
| 1006 | yield* manager.open(openInput({ terminalId: "sidecar" })); | ||
| 1007 | |||
| 1008 | yield* manager.close({ threadId: "thread-1" }); | ||
| 1009 | |||
| 1010 | const closedEvents = (yield* getEvents).filter( | ||
| 1011 | (event): event is Extract<TerminalEvent, { type: "closed" }> => event.type === "closed", | ||
| 1012 | ); | ||
| 1013 | expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]); | ||
| 1014 | }), | ||
| 1015 | ); | ||
| 1016 | |||
| 854 | it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => | 1017 | it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => |
| 855 | Effect.gen(function* () { | 1018 | Effect.gen(function* () { |
| 856 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { | 1019 | const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { |
| ... | @@ -1108,12 +1271,11 @@ it.layer( | ... | @@ -1108,12 +1271,11 @@ it.layer( |
| 1108 | const { manager, ptyAdapter } = yield* createManager(5, { | 1271 | const { manager, ptyAdapter } = yield* createManager(5, { |
| 1109 | ptyAdapter: new FakePtyAdapter("async"), | 1272 | ptyAdapter: new FakePtyAdapter("async"), |
| 1110 | }); | 1273 | }); |
| 1111 | const scope = yield* Effect.scope; | ||
| 1112 | const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); | 1274 | const subscriberEvents = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); |
| 1113 | const unsubscribe = yield* manager.subscribe((event) => | 1275 | const unsubscribe = yield* manager.subscribe((event) => |
| 1114 | Ref.update(subscriberEvents, (events) => [...events, event]), | 1276 | Ref.update(subscriberEvents, (events) => [...events, event]), |
| 1115 | ); | 1277 | ); |
| 1116 | yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); | 1278 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); |
| 1117 | 1279 | ||
| 1118 | yield* manager.open(openInput()); | 1280 | yield* manager.open(openInput()); |
| 1119 | const process = ptyAdapter.processes[0]; | 1281 | const process = ptyAdapter.processes[0]; |
| ... | @@ -1133,6 +1295,157 @@ it.layer( | ... | @@ -1133,6 +1295,157 @@ it.layer( |
| 1133 | }), | 1295 | }), |
| 1134 | ); | 1296 | ); |
| 1135 | 1297 | ||
| 1298 | it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () => | ||
| 1299 | Effect.gen(function* () { | ||
| 1300 | const { manager } = yield* createManager(); | ||
| 1301 | yield* manager.open(openInput({ threadId: "existing-thread" })); | ||
| 1302 | |||
| 1303 | const metadataEvents = yield* Ref.make<ReadonlyArray<TerminalMetadataStreamEvent>>([]); | ||
| 1304 | const unsubscribe = yield* manager.subscribeMetadata((event) => | ||
| 1305 | Ref.update(metadataEvents, (events) => [...events, event]), | ||
| 1306 | ); | ||
| 1307 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1308 | |||
| 1309 | const initialEvents = yield* Ref.get(metadataEvents); | ||
| 1310 | expect(initialEvents[0]).toMatchObject({ | ||
| 1311 | type: "snapshot", | ||
| 1312 | terminals: [ | ||
| 1313 | { | ||
| 1314 | threadId: "existing-thread", | ||
| 1315 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1316 | }, | ||
| 1317 | ], | ||
| 1318 | }); | ||
| 1319 | |||
| 1320 | yield* manager.open(openInput({ threadId: "new-thread" })); | ||
| 1321 | |||
| 1322 | yield* waitFor( | ||
| 1323 | Effect.map(Ref.get(metadataEvents), (events) => | ||
| 1324 | events.some( | ||
| 1325 | (event) => | ||
| 1326 | event.type === "upsert" && | ||
| 1327 | event.terminal.threadId === "new-thread" && | ||
| 1328 | event.terminal.terminalId === DEFAULT_TERMINAL_ID, | ||
| 1329 | ), | ||
| 1330 | ), | ||
| 1331 | "1200 millis", | ||
| 1332 | ); | ||
| 1333 | |||
| 1334 | yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID }); | ||
| 1335 | |||
| 1336 | yield* waitFor( | ||
| 1337 | Effect.map(Ref.get(metadataEvents), (events) => | ||
| 1338 | events.some( | ||
| 1339 | (event) => | ||
| 1340 | event.type === "remove" && | ||
| 1341 | event.threadId === "new-thread" && | ||
| 1342 | event.terminalId === DEFAULT_TERMINAL_ID, | ||
| 1343 | ), | ||
| 1344 | ), | ||
| 1345 | "1200 millis", | ||
| 1346 | ); | ||
| 1347 | }), | ||
| 1348 | ); | ||
| 1349 | |||
| 1350 | it.effect("removes terminal metadata subscriptions when initial delivery fails", () => | ||
| 1351 | Effect.gen(function* () { | ||
| 1352 | const { manager } = yield* createManager(); | ||
| 1353 | yield* manager.open(openInput({ threadId: "existing-thread" })); | ||
| 1354 | |||
| 1355 | const leakedLiveEvents = yield* Ref.make(0); | ||
| 1356 | const exit = yield* Effect.exit( | ||
| 1357 | manager.subscribeMetadata((event) => | ||
| 1358 | event.type === "snapshot" | ||
| 1359 | ? Effect.die("snapshot listener failed") | ||
| 1360 | : Ref.update(leakedLiveEvents, (count) => count + 1), | ||
| 1361 | ), | ||
| 1362 | ); | ||
| 1363 | |||
| 1364 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 1365 | |||
| 1366 | yield* manager.open(openInput({ threadId: "new-thread" })); | ||
| 1367 | expect(yield* Ref.get(leakedLiveEvents)).toBe(0); | ||
| 1368 | }), | ||
| 1369 | ); | ||
| 1370 | |||
| 1371 | it.effect( | ||
| 1372 | "streams attach snapshots followed by live events without duplicate start snapshots", | ||
| 1373 | () => | ||
| 1374 | Effect.gen(function* () { | ||
| 1375 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1376 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1377 | }); | ||
| 1378 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 1379 | const unsubscribe = yield* manager.attachStream(openInput(), (event) => | ||
| 1380 | Ref.update(attachEvents, (events) => [...events, event]), | ||
| 1381 | ); | ||
| 1382 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1383 | |||
| 1384 | const process = ptyAdapter.processes[0]; | ||
| 1385 | expect(process).toBeDefined(); | ||
| 1386 | if (!process) return; | ||
| 1387 | |||
| 1388 | expect(yield* Ref.get(attachEvents)).toMatchObject([ | ||
| 1389 | { | ||
| 1390 | type: "snapshot", | ||
| 1391 | snapshot: { | ||
| 1392 | threadId: "thread-1", | ||
| 1393 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1394 | }, | ||
| 1395 | }, | ||
| 1396 | ]); | ||
| 1397 | |||
| 1398 | process.emitData("hello from attach\n"); | ||
| 1399 | |||
| 1400 | yield* waitFor( | ||
| 1401 | Effect.map(Ref.get(attachEvents), (events) => | ||
| 1402 | events.some((event) => event.type === "output" && event.data === "hello from attach\n"), | ||
| 1403 | ), | ||
| 1404 | "1200 millis", | ||
| 1405 | ); | ||
| 1406 | |||
| 1407 | const events = yield* Ref.get(attachEvents); | ||
| 1408 | expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1); | ||
| 1409 | }), | ||
| 1410 | ); | ||
| 1411 | |||
| 1412 | it.effect("buffers attach output delivered during the initial snapshot callback", () => | ||
| 1413 | Effect.gen(function* () { | ||
| 1414 | const { manager, ptyAdapter } = yield* createManager(5, { | ||
| 1415 | ptyAdapter: new FakePtyAdapter("async"), | ||
| 1416 | }); | ||
| 1417 | yield* manager.open(openInput()); | ||
| 1418 | |||
| 1419 | const process = ptyAdapter.processes[0]; | ||
| 1420 | expect(process).toBeDefined(); | ||
| 1421 | if (!process) return; | ||
| 1422 | |||
| 1423 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 1424 | const unsubscribe = yield* manager.attachStream(openInput(), (event) => | ||
| 1425 | Effect.gen(function* () { | ||
| 1426 | yield* Ref.update(attachEvents, (events) => [...events, event]); | ||
| 1427 | if (event.type === "snapshot") { | ||
| 1428 | yield* Effect.sync(() => process.emitData("during snapshot\n")); | ||
| 1429 | yield* Effect.yieldNow; | ||
| 1430 | } | ||
| 1431 | }), | ||
| 1432 | ); | ||
| 1433 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1434 | |||
| 1435 | yield* waitFor( | ||
| 1436 | Effect.map(Ref.get(attachEvents), (events) => | ||
| 1437 | events.some((event) => event.type === "output" && event.data === "during snapshot\n"), | ||
| 1438 | ), | ||
| 1439 | "1200 millis", | ||
| 1440 | ); | ||
| 1441 | |||
| 1442 | expect(yield* Ref.get(attachEvents)).toMatchObject([ | ||
| 1443 | { type: "snapshot" }, | ||
| 1444 | { type: "output", data: "during snapshot\n" }, | ||
| 1445 | ]); | ||
| 1446 | }), | ||
| 1447 | ); | ||
| 1448 | |||
| 1136 | it.effect("preserves queued PTY output ordering through exit callbacks", () => | 1449 | it.effect("preserves queued PTY output ordering through exit callbacks", () => |
| 1137 | Effect.gen(function* () { | 1450 | Effect.gen(function* () { |
| 1138 | const { manager, ptyAdapter, getEvents } = yield* createManager(5, { | 1451 | const { manager, ptyAdapter, getEvents } = yield* createManager(5, { |
| ... | @@ -1162,10 +1475,25 @@ it.layer( | ... | @@ -1162,10 +1475,25 @@ it.layer( |
| 1162 | (event) => event.type === "output" || event.type === "exited", | 1475 | (event) => event.type === "output" || event.type === "exited", |
| 1163 | ); | 1476 | ); |
| 1164 | expect(relevant).toEqual([ | 1477 | expect(relevant).toEqual([ |
| 1165 | expect.objectContaining({ type: "output", data: "first\n" }), | 1478 | expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }), |
| 1166 | expect.objectContaining({ type: "output", data: "second\n" }), | 1479 | expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }), |
| 1167 | expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0 }), | 1480 | expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }), |
| 1168 | ]); | 1481 | ]); |
| 1482 | |||
| 1483 | const attachEvents = yield* Ref.make<ReadonlyArray<TerminalAttachStreamEvent>>([]); | ||
| 1484 | const unsubscribe = yield* manager.attachStream( | ||
| 1485 | { | ||
| 1486 | threadId: "thread-1", | ||
| 1487 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1488 | }, | ||
| 1489 | (event) => Ref.update(attachEvents, (events) => [...events, event]), | ||
| 1490 | ); | ||
| 1491 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | ||
| 1492 | |||
| 1493 | const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); | ||
| 1494 | expect(snapshot).toBeDefined(); | ||
| 1495 | if (!snapshot || snapshot.type !== "snapshot") return; | ||
| 1496 | expect(snapshot.snapshot.sequence).toBe(4); | ||
| 1169 | }), | 1497 | }), |
| 1170 | ); | 1498 | ); |
| 1171 | 1499 |
apps/server/src/terminal/Layers/Manager.ts+619-192| ... | @@ -1,10 +1,16 @@ | ... | @@ -1,10 +1,16 @@ |
| 1 | import { | 1 | import { |
| 2 | DEFAULT_TERMINAL_ID, | 2 | DEFAULT_TERMINAL_ID, |
| 3 | type TerminalAttachInput, | ||
| 4 | type TerminalAttachStreamEvent, | ||
| 3 | type TerminalEvent, | 5 | type TerminalEvent, |
| 6 | type TerminalMetadataStreamEvent, | ||
| 7 | type TerminalOpenInput, | ||
| 4 | type TerminalSessionSnapshot, | 8 | type TerminalSessionSnapshot, |
| 5 | type TerminalSessionStatus, | 9 | type TerminalSessionStatus, |
| 10 | type TerminalSummary, | ||
| 6 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 7 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; | 12 | import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; |
| 13 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | ||
| 8 | import * as DateTime from "effect/DateTime"; | 14 | import * as DateTime from "effect/DateTime"; |
| 9 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 10 | import * as Encoding from "effect/Encoding"; | 16 | import * as Encoding from "effect/Encoding"; |
| ... | @@ -52,6 +58,7 @@ const DEFAULT_OPEN_COLS = 120; | ... | @@ -52,6 +58,7 @@ const DEFAULT_OPEN_COLS = 120; |
| 52 | const DEFAULT_OPEN_ROWS = 30; | 58 | const DEFAULT_OPEN_ROWS = 30; |
| 53 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); | 59 | const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); |
| 54 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 60 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 61 | const MAX_TERMINAL_LABEL_LENGTH = 128; | ||
| 55 | 62 | ||
| 56 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( | 63 | class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubprocessCheckError>()( |
| 57 | "TerminalSubprocessCheckError", | 64 | "TerminalSubprocessCheckError", |
| ... | @@ -72,10 +79,15 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess | ... | @@ -72,10 +79,15 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess |
| 72 | }, | 79 | }, |
| 73 | ) {} | 80 | ) {} |
| 74 | 81 | ||
| 75 | interface TerminalSubprocessChecker { | 82 | interface TerminalSubprocessInspectResult { |
| 83 | readonly hasRunningSubprocess: boolean; | ||
| 84 | readonly childCommand: string | null; | ||
| 85 | } | ||
| 86 | |||
| 87 | interface TerminalSubprocessInspector { | ||
| 76 | ( | 88 | ( |
| 77 | terminalPid: number, | 89 | terminalPid: number, |
| 78 | ): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner>; | 90 | ): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>; |
| 79 | } | 91 | } |
| 80 | 92 | ||
| 81 | interface ShellCandidate { | 93 | interface ShellCandidate { |
| ... | @@ -108,12 +120,15 @@ interface TerminalSessionState { | ... | @@ -108,12 +120,15 @@ interface TerminalSessionState { |
| 108 | exitCode: number | null; | 120 | exitCode: number | null; |
| 109 | exitSignal: number | null; | 121 | exitSignal: number | null; |
| 110 | updatedAt: string; | 122 | updatedAt: string; |
| 123 | eventSequence: number; | ||
| 111 | cols: number; | 124 | cols: number; |
| 112 | rows: number; | 125 | rows: number; |
| 113 | process: PtyProcess | null; | 126 | process: PtyProcess | null; |
| 114 | unsubscribeData: (() => void) | null; | 127 | unsubscribeData: (() => void) | null; |
| 115 | unsubscribeExit: (() => void) | null; | 128 | unsubscribeExit: (() => void) | null; |
| 116 | hasRunningSubprocess: boolean; | 129 | hasRunningSubprocess: boolean; |
| 130 | /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ | ||
| 131 | childCommandLabel: string | null; | ||
| 117 | runtimeEnv: Record<string, string> | null; | 132 | runtimeEnv: Record<string, string> | null; |
| 118 | } | 133 | } |
| 119 | 134 | ||
| ... | @@ -130,6 +145,7 @@ type DrainProcessEventAction = | ... | @@ -130,6 +145,7 @@ type DrainProcessEventAction = |
| 130 | type: "output"; | 145 | type: "output"; |
| 131 | threadId: string; | 146 | threadId: string; |
| 132 | terminalId: string; | 147 | terminalId: string; |
| 148 | sequence: number; | ||
| 133 | history: string | null; | 149 | history: string | null; |
| 134 | data: string; | 150 | data: string; |
| 135 | } | 151 | } |
| ... | @@ -138,6 +154,7 @@ type DrainProcessEventAction = | ... | @@ -138,6 +154,7 @@ type DrainProcessEventAction = |
| 138 | process: PtyProcess | null; | 154 | process: PtyProcess | null; |
| 139 | threadId: string; | 155 | threadId: string; |
| 140 | terminalId: string; | 156 | terminalId: string; |
| 157 | sequence: number; | ||
| 141 | exitCode: number | null; | 158 | exitCode: number | null; |
| 142 | exitSignal: number | null; | 159 | exitSignal: number | null; |
| 143 | }; | 160 | }; |
| ... | @@ -147,6 +164,39 @@ interface TerminalManagerState { | ... | @@ -147,6 +164,39 @@ interface TerminalManagerState { |
| 147 | killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>; | 164 | killFibers: Map<PtyProcess, Fiber.Fiber<void, never>>; |
| 148 | } | 165 | } |
| 149 | 166 | ||
| 167 | function truncateTerminalWireLabel(value: string): string { | ||
| 168 | if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value; | ||
| 169 | return value.slice(0, MAX_TERMINAL_LABEL_LENGTH); | ||
| 170 | } | ||
| 171 | |||
| 172 | function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null { | ||
| 173 | let trimmed = raw.trim(); | ||
| 174 | if (trimmed.length === 0) return null; | ||
| 175 | if ( | ||
| 176 | (trimmed.startsWith("[") && trimmed.endsWith("]")) || | ||
| 177 | (trimmed.startsWith("(") && trimmed.endsWith(")")) | ||
| 178 | ) { | ||
| 179 | trimmed = trimmed.slice(1, -1).trim(); | ||
| 180 | } | ||
| 181 | const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); | ||
| 182 | if (firstToken.length === 0) return null; | ||
| 183 | const separators = platform === "win32" ? /[\\/]/ : /\//; | ||
| 184 | const base = firstToken.split(separators).at(-1) ?? firstToken; | ||
| 185 | const withoutExe = | ||
| 186 | platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; | ||
| 187 | return withoutExe.length > 0 ? withoutExe : null; | ||
| 188 | } | ||
| 189 | |||
| 190 | function terminalWireLabel(session: TerminalSessionState): string { | ||
| 191 | if (session.hasRunningSubprocess && session.childCommandLabel) { | ||
| 192 | const trimmed = session.childCommandLabel.trim(); | ||
| 193 | if (trimmed.length > 0) { | ||
| 194 | return truncateTerminalWireLabel(trimmed); | ||
| 195 | } | ||
| 196 | } | ||
| 197 | return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); | ||
| 198 | } | ||
| 199 | |||
| 150 | function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { | 200 | function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { |
| 151 | return { | 201 | return { |
| 152 | threadId: session.threadId, | 202 | threadId: session.threadId, |
| ... | @@ -158,10 +208,83 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { | ... | @@ -158,10 +208,83 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { |
| 158 | history: session.history, | 208 | history: session.history, |
| 159 | exitCode: session.exitCode, | 209 | exitCode: session.exitCode, |
| 160 | exitSignal: session.exitSignal, | 210 | exitSignal: session.exitSignal, |
| 211 | label: terminalWireLabel(session), | ||
| 161 | updatedAt: session.updatedAt, | 212 | updatedAt: session.updatedAt, |
| 213 | sequence: session.eventSequence, | ||
| 162 | }; | 214 | }; |
| 163 | } | 215 | } |
| 164 | 216 | ||
| 217 | function summary(session: TerminalSessionState): TerminalSummary { | ||
| 218 | return { | ||
| 219 | threadId: session.threadId, | ||
| 220 | terminalId: session.terminalId, | ||
| 221 | cwd: session.cwd, | ||
| 222 | worktreePath: session.worktreePath, | ||
| 223 | status: session.status, | ||
| 224 | pid: session.pid, | ||
| 225 | exitCode: session.exitCode, | ||
| 226 | exitSignal: session.exitSignal, | ||
| 227 | hasRunningSubprocess: session.hasRunningSubprocess, | ||
| 228 | label: terminalWireLabel(session), | ||
| 229 | updatedAt: session.updatedAt, | ||
| 230 | }; | ||
| 231 | } | ||
| 232 | |||
| 233 | function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean { | ||
| 234 | switch (event.type) { | ||
| 235 | case "started": | ||
| 236 | case "restarted": | ||
| 237 | case "exited": | ||
| 238 | case "closed": | ||
| 239 | case "error": | ||
| 240 | case "activity": | ||
| 241 | return true; | ||
| 242 | case "output": | ||
| 243 | case "cleared": | ||
| 244 | return false; | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 248 | function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null { | ||
| 249 | switch (event.type) { | ||
| 250 | case "started": | ||
| 251 | return { | ||
| 252 | type: "snapshot", | ||
| 253 | snapshot: event.snapshot, | ||
| 254 | }; | ||
| 255 | case "output": | ||
| 256 | case "exited": | ||
| 257 | case "closed": | ||
| 258 | case "error": | ||
| 259 | case "cleared": | ||
| 260 | case "restarted": | ||
| 261 | case "activity": | ||
| 262 | return event; | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | function isDuplicateAttachSnapshotEvent( | ||
| 267 | event: TerminalEvent, | ||
| 268 | initialSnapshot: TerminalSessionSnapshot, | ||
| 269 | ) { | ||
| 270 | return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number" | ||
| 271 | ? event.sequence <= initialSnapshot.sequence | ||
| 272 | : event.type === "started" && | ||
| 273 | event.snapshot.threadId === initialSnapshot.threadId && | ||
| 274 | event.snapshot.terminalId === initialSnapshot.terminalId && | ||
| 275 | event.snapshot.updatedAt <= initialSnapshot.updatedAt; | ||
| 276 | } | ||
| 277 | |||
| 278 | function advanceEventSequence(session: TerminalSessionState): { | ||
| 279 | readonly updatedAt: string; | ||
| 280 | readonly sequence: number; | ||
| 281 | } { | ||
| 282 | const updatedAt = DateTime.formatIso(DateTime.nowUnsafe()); | ||
| 283 | session.eventSequence += 1; | ||
| 284 | session.updatedAt = updatedAt; | ||
| 285 | return { updatedAt, sequence: session.eventSequence }; | ||
| 286 | } | ||
| 287 | |||
| 165 | function cleanupProcessHandles(session: TerminalSessionState): void { | 288 | function cleanupProcessHandles(session: TerminalSessionState): void { |
| 166 | session.unsubscribeData?.(); | 289 | session.unsubscribeData?.(); |
| 167 | session.unsubscribeData = null; | 290 | session.unsubscribeData = null; |
| ... | @@ -364,13 +487,29 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { | ... | @@ -364,13 +487,29 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { |
| 364 | ); | 487 | ); |
| 365 | } | 488 | } |
| 366 | 489 | ||
| 367 | function checkWindowsSubprocessActivity( | 490 | function parseFirstChildPidFromPgrep(stdout: string): number | null { |
| 491 | for (const line of stdout.split(/\r?\n/g)) { | ||
| 492 | const n = Number.parseInt(line.trim(), 10); | ||
| 493 | if (Number.isInteger(n) && n > 0) { | ||
| 494 | return n; | ||
| 495 | } | ||
| 496 | } | ||
| 497 | return null; | ||
| 498 | } | ||
| 499 | |||
| 500 | function windowsInspectSubprocess( | ||
| 368 | terminalPid: number, | 501 | terminalPid: number, |
| 369 | ): Effect.Effect<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> { | 502 | platform: NodeJS.Platform, |
| 503 | ): Effect.Effect< | ||
| 504 | TerminalSubprocessInspectResult, | ||
| 505 | TerminalSubprocessCheckError, | ||
| 506 | ProcessRunner.ProcessRunner | ||
| 507 | > { | ||
| 370 | const command = [ | 508 | const command = [ |
| 371 | `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, | 509 | `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`, |
| 372 | "if ($children) { exit 0 }", | 510 | "if ($null -eq $c) { exit 1 }", |
| 373 | "exit 1", | 511 | "Write-Output $c.Name", |
| 512 | "exit 0", | ||
| 374 | ].join("; "); | 513 | ].join("; "); |
| 375 | return Effect.gen(function* () { | 514 | return Effect.gen(function* () { |
| 376 | const processRunner = yield* ProcessRunner.ProcessRunner; | 515 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| ... | @@ -384,11 +523,24 @@ function checkWindowsSubprocessActivity( | ... | @@ -384,11 +523,24 @@ function checkWindowsSubprocessActivity( |
| 384 | timeoutBehavior: "timedOutResult", | 523 | timeoutBehavior: "timedOutResult", |
| 385 | }); | 524 | }); |
| 386 | }).pipe( | 525 | }).pipe( |
| 387 | Effect.map((result) => result.code === 0), | 526 | Effect.map((result) => { |
| 527 | if (result.code !== 0) { | ||
| 528 | return { hasRunningSubprocess: false, childCommand: null } as const; | ||
| 529 | } | ||
| 530 | const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? ""; | ||
| 531 | if (name.length === 0) { | ||
| 532 | return { hasRunningSubprocess: true, childCommand: null } as const; | ||
| 533 | } | ||
| 534 | const normalized = normalizeChildCommandName(name, platform); | ||
| 535 | return { | ||
| 536 | hasRunningSubprocess: true, | ||
| 537 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, | ||
| 538 | } as const; | ||
| 539 | }), | ||
| 388 | Effect.mapError( | 540 | Effect.mapError( |
| 389 | (cause) => | 541 | (cause) => |
| 390 | new TerminalSubprocessCheckError({ | 542 | new TerminalSubprocessCheckError({ |
| 391 | message: "Failed to check Windows terminal subprocess activity.", | 543 | message: "Failed to inspect Windows terminal subprocesses.", |
| 392 | cause, | 544 | cause, |
| 393 | terminalPid, | 545 | terminalPid, |
| 394 | command: "powershell", | 546 | command: "powershell", |
| ... | @@ -397,9 +549,14 @@ function checkWindowsSubprocessActivity( | ... | @@ -397,9 +549,14 @@ function checkWindowsSubprocessActivity( |
| 397 | ); | 549 | ); |
| 398 | } | 550 | } |
| 399 | 551 | ||
| 400 | const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* ( | 552 | const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( |
| 401 | terminalPid: number, | 553 | terminalPid: number, |
| 402 | ): Effect.fn.Return<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> { | 554 | platform: NodeJS.Platform, |
| 555 | ): Effect.fn.Return< | ||
| 556 | TerminalSubprocessInspectResult, | ||
| 557 | TerminalSubprocessCheckError, | ||
| 558 | ProcessRunner.ProcessRunner | ||
| 559 | > { | ||
| 403 | const processRunner = yield* ProcessRunner.ProcessRunner; | 560 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 404 | const runPgrep = processRunner | 561 | const runPgrep = processRunner |
| 405 | .run({ | 562 | .run({ |
| ... | @@ -443,45 +600,88 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct | ... | @@ -443,45 +600,88 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct |
| 443 | ), | 600 | ), |
| 444 | ); | 601 | ); |
| 445 | 602 | ||
| 603 | let childPid: number | null = null; | ||
| 604 | |||
| 446 | const pgrepResult = yield* Effect.exit(runPgrep); | 605 | const pgrepResult = yield* Effect.exit(runPgrep); |
| 447 | if (pgrepResult._tag === "Success") { | 606 | if (pgrepResult._tag === "Success") { |
| 448 | if (pgrepResult.value.code === 0) { | 607 | if (pgrepResult.value.code === 0) { |
| 449 | return pgrepResult.value.stdout.trim().length > 0; | 608 | childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); |
| 450 | } | 609 | } else if (pgrepResult.value.code === 1) { |
| 451 | if (pgrepResult.value.code === 1) { | 610 | return { hasRunningSubprocess: false, childCommand: null }; |
| 452 | return false; | ||
| 453 | } | 611 | } |
| 454 | } | 612 | } |
| 455 | 613 | ||
| 456 | const psResult = yield* Effect.exit(runPs); | 614 | if (childPid === null) { |
| 457 | if (psResult._tag === "Failure" || psResult.value.code !== 0) { | 615 | const psResult = yield* Effect.exit(runPs); |
| 458 | return false; | 616 | if (psResult._tag === "Failure" || psResult.value.code !== 0) { |
| 617 | return { hasRunningSubprocess: false, childCommand: null }; | ||
| 618 | } | ||
| 619 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | ||
| 620 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | ||
| 621 | const pid = Number(pidRaw); | ||
| 622 | const ppid = Number(ppidRaw); | ||
| 623 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | ||
| 624 | if (ppid === terminalPid) { | ||
| 625 | childPid = pid; | ||
| 626 | break; | ||
| 627 | } | ||
| 628 | } | ||
| 459 | } | 629 | } |
| 460 | 630 | ||
| 461 | for (const line of psResult.value.stdout.split(/\r?\n/g)) { | 631 | if (childPid === null) { |
| 462 | const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); | 632 | return { hasRunningSubprocess: false, childCommand: null }; |
| 463 | const pid = Number(pidRaw); | ||
| 464 | const ppid = Number(ppidRaw); | ||
| 465 | if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; | ||
| 466 | if (ppid === terminalPid) { | ||
| 467 | return true; | ||
| 468 | } | ||
| 469 | } | 633 | } |
| 470 | return false; | ||
| 471 | }); | ||
| 472 | 634 | ||
| 473 | const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* ( | 635 | const runComm = processRunner.run({ |
| 474 | terminalPid: number, | 636 | command: "ps", |
| 475 | ): Effect.fn.Return<boolean, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner> { | 637 | args: ["-p", String(childPid), "-o", "comm="], |
| 476 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | 638 | timeout: "1 second", |
| 477 | return false; | 639 | maxOutputBytes: 8_192, |
| 640 | outputMode: "truncate", | ||
| 641 | timeoutBehavior: "timedOutResult", | ||
| 642 | }); | ||
| 643 | |||
| 644 | const commResult = yield* Effect.exit(runComm); | ||
| 645 | let rawComm: string | null = null; | ||
| 646 | if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { | ||
| 647 | rawComm = commResult.value.stdout.trim(); | ||
| 478 | } | 648 | } |
| 479 | if (process.platform === "win32") { | 649 | |
| 480 | return yield* checkWindowsSubprocessActivity(terminalPid); | 650 | if (!rawComm || rawComm.length === 0) { |
| 651 | const runArgs = processRunner.run({ | ||
| 652 | command: "ps", | ||
| 653 | args: ["-p", String(childPid), "-o", "args="], | ||
| 654 | timeout: "1 second", | ||
| 655 | maxOutputBytes: 16_384, | ||
| 656 | outputMode: "truncate", | ||
| 657 | timeoutBehavior: "timedOutResult", | ||
| 658 | }); | ||
| 659 | const argsResult = yield* Effect.exit(runArgs); | ||
| 660 | if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { | ||
| 661 | const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; | ||
| 662 | rawComm = first.length > 0 ? first : null; | ||
| 663 | } | ||
| 481 | } | 664 | } |
| 482 | return yield* checkPosixSubprocessActivity(terminalPid); | 665 | |
| 666 | const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; | ||
| 667 | return { | ||
| 668 | hasRunningSubprocess: true, | ||
| 669 | childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, | ||
| 670 | }; | ||
| 483 | }); | 671 | }); |
| 484 | 672 | ||
| 673 | function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { | ||
| 674 | return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { | ||
| 675 | if (!Number.isInteger(terminalPid) || terminalPid <= 0) { | ||
| 676 | return { hasRunningSubprocess: false, childCommand: null }; | ||
| 677 | } | ||
| 678 | if (platform === "win32") { | ||
| 679 | return yield* windowsInspectSubprocess(terminalPid, platform); | ||
| 680 | } | ||
| 681 | return yield* posixInspectSubprocess(terminalPid, platform); | ||
| 682 | }); | ||
| 683 | } | ||
| 684 | |||
| 485 | function capHistory(history: string, maxLines: number): string { | 685 | function capHistory(history: string, maxLines: number): string { |
| 486 | if (history.length === 0) return history; | 686 | if (history.length === 0) return history; |
| 487 | const hasTrailingNewline = history.endsWith("\n"); | 687 | const hasTrailingNewline = history.endsWith("\n"); |
| ... | @@ -729,7 +929,7 @@ interface TerminalManagerOptions { | ... | @@ -729,7 +929,7 @@ interface TerminalManagerOptions { |
| 729 | shellResolver?: () => string; | 929 | shellResolver?: () => string; |
| 730 | platform?: NodeJS.Platform; | 930 | platform?: NodeJS.Platform; |
| 731 | env?: NodeJS.ProcessEnv; | 931 | env?: NodeJS.ProcessEnv; |
| 732 | subprocessChecker?: TerminalSubprocessChecker; | 932 | subprocessInspector?: TerminalSubprocessInspector; |
| 733 | subprocessPollIntervalMs?: number; | 933 | subprocessPollIntervalMs?: number; |
| 734 | processKillGraceMs?: number; | 934 | processKillGraceMs?: number; |
| 735 | maxRetainedInactiveSessions?: number; | 935 | maxRetainedInactiveSessions?: number; |
| ... | @@ -757,10 +957,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -757,10 +957,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 757 | const baseEnv = options.env ?? process.env; | 957 | const baseEnv = options.env ?? process.env; |
| 758 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); | 958 | const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); |
| 759 | const processRunner = yield* ProcessRunner.ProcessRunner; | 959 | const processRunner = yield* ProcessRunner.ProcessRunner; |
| 760 | const subprocessChecker = | 960 | const subprocessInspector = |
| 761 | options.subprocessChecker ?? | 961 | options.subprocessInspector ?? |
| 762 | ((terminalPid) => | 962 | ((terminalPid) => |
| 763 | defaultSubprocessChecker(terminalPid).pipe( | 963 | defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe( |
| 764 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), | 964 | Effect.provideService(ProcessRunner.ProcessRunner, processRunner), |
| 765 | )); | 965 | )); |
| 766 | const subprocessPollIntervalMs = | 966 | const subprocessPollIntervalMs = |
| ... | @@ -1200,7 +1400,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1200,7 +1400,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1200 | expectedPid: number, | 1400 | expectedPid: number, |
| 1201 | ) { | 1401 | ) { |
| 1202 | while (true) { | 1402 | while (true) { |
| 1203 | const updatedAt = yield* nowIso; | ||
| 1204 | const action: DrainProcessEventAction = yield* Effect.sync(() => { | 1403 | const action: DrainProcessEventAction = yield* Effect.sync(() => { |
| 1205 | if (session.pid !== expectedPid || !session.process || session.status !== "running") { | 1404 | if (session.pid !== expectedPid || !session.process || session.status !== "running") { |
| 1206 | session.pendingProcessEvents = []; | 1405 | session.pendingProcessEvents = []; |
| ... | @@ -1235,12 +1434,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1235,12 +1434,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1235 | historyLineLimit, | 1434 | historyLineLimit, |
| 1236 | ); | 1435 | ); |
| 1237 | } | 1436 | } |
| 1238 | session.updatedAt = updatedAt; | 1437 | const eventStamp = advanceEventSequence(session); |
| 1239 | 1438 | ||
| 1240 | return { | 1439 | return { |
| 1241 | type: "output", | 1440 | type: "output", |
| 1242 | threadId: session.threadId, | 1441 | threadId: session.threadId, |
| 1243 | terminalId: session.terminalId, | 1442 | terminalId: session.terminalId, |
| 1443 | sequence: eventStamp.sequence, | ||
| 1244 | history: sanitized.visibleText.length > 0 ? session.history : null, | 1444 | history: sanitized.visibleText.length > 0 ? session.history : null, |
| 1245 | data: nextEvent.data, | 1445 | data: nextEvent.data, |
| 1246 | } as const; | 1446 | } as const; |
| ... | @@ -1251,6 +1451,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1251,6 +1451,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1251 | session.process = null; | 1451 | session.process = null; |
| 1252 | session.pid = null; | 1452 | session.pid = null; |
| 1253 | session.hasRunningSubprocess = false; | 1453 | session.hasRunningSubprocess = false; |
| 1454 | session.childCommandLabel = null; | ||
| 1254 | session.status = "exited"; | 1455 | session.status = "exited"; |
| 1255 | session.pendingHistoryControlSequence = ""; | 1456 | session.pendingHistoryControlSequence = ""; |
| 1256 | session.pendingProcessEvents = []; | 1457 | session.pendingProcessEvents = []; |
| ... | @@ -1262,13 +1463,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1262,13 +1463,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1262 | session.exitSignal = Number.isInteger(nextEvent.event.signal) | 1463 | session.exitSignal = Number.isInteger(nextEvent.event.signal) |
| 1263 | ? nextEvent.event.signal | 1464 | ? nextEvent.event.signal |
| 1264 | : null; | 1465 | : null; |
| 1265 | session.updatedAt = updatedAt; | 1466 | const eventStamp = advanceEventSequence(session); |
| 1266 | 1467 | ||
| 1267 | return { | 1468 | return { |
| 1268 | type: "exit", | 1469 | type: "exit", |
| 1269 | process, | 1470 | process, |
| 1270 | threadId: session.threadId, | 1471 | threadId: session.threadId, |
| 1271 | terminalId: session.terminalId, | 1472 | terminalId: session.terminalId, |
| 1473 | sequence: eventStamp.sequence, | ||
| 1272 | exitCode: session.exitCode, | 1474 | exitCode: session.exitCode, |
| 1273 | exitSignal: session.exitSignal, | 1475 | exitSignal: session.exitSignal, |
| 1274 | } as const; | 1476 | } as const; |
| ... | @@ -1283,24 +1485,22 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1283,24 +1485,22 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1283 | yield* queuePersist(action.threadId, action.terminalId, action.history); | 1485 | yield* queuePersist(action.threadId, action.terminalId, action.history); |
| 1284 | } | 1486 | } |
| 1285 | 1487 | ||
| 1286 | const createdAt = yield* nowIso; | ||
| 1287 | yield* publishEvent({ | 1488 | yield* publishEvent({ |
| 1288 | type: "output", | 1489 | type: "output", |
| 1289 | threadId: action.threadId, | 1490 | threadId: action.threadId, |
| 1290 | terminalId: action.terminalId, | 1491 | terminalId: action.terminalId, |
| 1291 | createdAt, | 1492 | sequence: action.sequence, |
| 1292 | data: action.data, | 1493 | data: action.data, |
| 1293 | }); | 1494 | }); |
| 1294 | continue; | 1495 | continue; |
| 1295 | } | 1496 | } |
| 1296 | 1497 | ||
| 1297 | yield* clearKillFiber(action.process); | 1498 | yield* clearKillFiber(action.process); |
| 1298 | const createdAt = yield* nowIso; | ||
| 1299 | yield* publishEvent({ | 1499 | yield* publishEvent({ |
| 1300 | type: "exited", | 1500 | type: "exited", |
| 1301 | threadId: action.threadId, | 1501 | threadId: action.threadId, |
| 1302 | terminalId: action.terminalId, | 1502 | terminalId: action.terminalId, |
| 1303 | createdAt, | 1503 | sequence: action.sequence, |
| 1304 | exitCode: action.exitCode, | 1504 | exitCode: action.exitCode, |
| 1305 | exitSignal: action.exitSignal, | 1505 | exitSignal: action.exitSignal, |
| 1306 | }); | 1506 | }); |
| ... | @@ -1321,6 +1521,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1321,6 +1521,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1321 | session.process = null; | 1521 | session.process = null; |
| 1322 | session.pid = null; | 1522 | session.pid = null; |
| 1323 | session.hasRunningSubprocess = false; | 1523 | session.hasRunningSubprocess = false; |
| 1524 | session.childCommandLabel = null; | ||
| 1324 | session.status = "exited"; | 1525 | session.status = "exited"; |
| 1325 | session.pendingHistoryControlSequence = ""; | 1526 | session.pendingHistoryControlSequence = ""; |
| 1326 | session.pendingProcessEvents = []; | 1527 | session.pendingProcessEvents = []; |
| ... | @@ -1415,6 +1616,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1415,6 +1616,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1415 | session.exitCode = null; | 1616 | session.exitCode = null; |
| 1416 | session.exitSignal = null; | 1617 | session.exitSignal = null; |
| 1417 | session.hasRunningSubprocess = false; | 1618 | session.hasRunningSubprocess = false; |
| 1619 | session.childCommandLabel = null; | ||
| 1418 | session.pendingProcessEvents = []; | 1620 | session.pendingProcessEvents = []; |
| 1419 | session.pendingProcessEventIndex = 0; | 1621 | session.pendingProcessEventIndex = 0; |
| 1420 | session.processEventDrainRunning = false; | 1622 | session.processEventDrainRunning = false; |
| ... | @@ -1449,23 +1651,25 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1449,23 +1651,25 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1449 | runFork(drainProcessEvents(session, processPid)); | 1651 | runFork(drainProcessEvents(session, processPid)); |
| 1450 | }); | 1652 | }); |
| 1451 | 1653 | ||
| 1452 | const runningAt = yield* nowIso; | 1654 | let eventStamp: ReturnType<typeof advanceEventSequence> = { |
| 1655 | updatedAt: session.updatedAt, | ||
| 1656 | sequence: session.eventSequence, | ||
| 1657 | }; | ||
| 1453 | yield* modifyManagerState((state) => { | 1658 | yield* modifyManagerState((state) => { |
| 1454 | session.process = ptyProcess; | 1659 | session.process = ptyProcess; |
| 1455 | session.pid = processPid; | 1660 | session.pid = processPid; |
| 1456 | session.status = "running"; | 1661 | session.status = "running"; |
| 1457 | session.updatedAt = runningAt; | ||
| 1458 | session.unsubscribeData = unsubscribeData; | 1662 | session.unsubscribeData = unsubscribeData; |
| 1459 | session.unsubscribeExit = unsubscribeExit; | 1663 | session.unsubscribeExit = unsubscribeExit; |
| 1664 | eventStamp = advanceEventSequence(session); | ||
| 1460 | return [undefined, state] as const; | 1665 | return [undefined, state] as const; |
| 1461 | }); | 1666 | }); |
| 1462 | 1667 | ||
| 1463 | const createdAt = yield* nowIso; | ||
| 1464 | yield* publishEvent({ | 1668 | yield* publishEvent({ |
| 1465 | type: eventType, | 1669 | type: eventType, |
| 1466 | threadId: session.threadId, | 1670 | threadId: session.threadId, |
| 1467 | terminalId: session.terminalId, | 1671 | terminalId: session.terminalId, |
| 1468 | createdAt, | 1672 | sequence: eventStamp.sequence, |
| 1469 | snapshot: snapshot(session), | 1673 | snapshot: snapshot(session), |
| 1470 | }); | 1674 | }); |
| 1471 | }), | 1675 | }), |
| ... | @@ -1482,7 +1686,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1482,7 +1686,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1482 | if (ptyProcess) { | 1686 | if (ptyProcess) { |
| 1483 | yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); | 1687 | yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); |
| 1484 | } | 1688 | } |
| 1485 | const erroredAt = yield* nowIso; | ||
| 1486 | 1689 | ||
| 1487 | yield* modifyManagerState((state) => { | 1690 | yield* modifyManagerState((state) => { |
| 1488 | session.status = "error"; | 1691 | session.status = "error"; |
| ... | @@ -1491,22 +1694,22 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1491,22 +1694,22 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1491 | session.unsubscribeData = null; | 1694 | session.unsubscribeData = null; |
| 1492 | session.unsubscribeExit = null; | 1695 | session.unsubscribeExit = null; |
| 1493 | session.hasRunningSubprocess = false; | 1696 | session.hasRunningSubprocess = false; |
| 1697 | session.childCommandLabel = null; | ||
| 1494 | session.pendingProcessEvents = []; | 1698 | session.pendingProcessEvents = []; |
| 1495 | session.pendingProcessEventIndex = 0; | 1699 | session.pendingProcessEventIndex = 0; |
| 1496 | session.processEventDrainRunning = false; | 1700 | session.processEventDrainRunning = false; |
| 1497 | session.updatedAt = erroredAt; | 1701 | advanceEventSequence(session); |
| 1498 | return [undefined, state] as const; | 1702 | return [undefined, state] as const; |
| 1499 | }); | 1703 | }); |
| 1500 | 1704 | ||
| 1501 | yield* evictInactiveSessionsIfNeeded(); | 1705 | yield* evictInactiveSessionsIfNeeded(); |
| 1502 | 1706 | ||
| 1503 | const message = error.message; | 1707 | const message = error.message; |
| 1504 | const createdAt = yield* nowIso; | ||
| 1505 | yield* publishEvent({ | 1708 | yield* publishEvent({ |
| 1506 | type: "error", | 1709 | type: "error", |
| 1507 | threadId: session.threadId, | 1710 | threadId: session.threadId, |
| 1508 | terminalId: session.terminalId, | 1711 | terminalId: session.terminalId, |
| 1509 | createdAt, | 1712 | sequence: session.eventSequence, |
| 1510 | message, | 1713 | message, |
| 1511 | }); | 1714 | }); |
| 1512 | yield* Effect.logError("failed to start terminal", { | 1715 | yield* Effect.logError("failed to start terminal", { |
| ... | @@ -1525,6 +1728,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1525,6 +1728,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1525 | ) { | 1728 | ) { |
| 1526 | const key = toSessionKey(threadId, terminalId); | 1729 | const key = toSessionKey(threadId, terminalId); |
| 1527 | const session = yield* getSession(threadId, terminalId); | 1730 | const session = yield* getSession(threadId, terminalId); |
| 1731 | const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; | ||
| 1528 | 1732 | ||
| 1529 | if (Option.isSome(session)) { | 1733 | if (Option.isSome(session)) { |
| 1530 | yield* stopProcess(session.value); | 1734 | yield* stopProcess(session.value); |
| ... | @@ -1533,15 +1737,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1533,15 +1737,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1533 | 1737 | ||
| 1534 | yield* flushPersist(threadId, terminalId); | 1738 | yield* flushPersist(threadId, terminalId); |
| 1535 | 1739 | ||
| 1536 | yield* modifyManagerState((state) => { | 1740 | const removed = yield* modifyManagerState((state) => { |
| 1537 | if (!state.sessions.has(key)) { | 1741 | if (!state.sessions.has(key)) { |
| 1538 | return [undefined, state] as const; | 1742 | return [false, state] as const; |
| 1539 | } | 1743 | } |
| 1540 | const sessions = new Map(state.sessions); | 1744 | const sessions = new Map(state.sessions); |
| 1541 | sessions.delete(key); | 1745 | sessions.delete(key); |
| 1542 | return [undefined, { ...state, sessions }] as const; | 1746 | return [true, { ...state, sessions }] as const; |
| 1543 | }); | 1747 | }); |
| 1544 | 1748 | ||
| 1749 | if (removed) { | ||
| 1750 | yield* publishEvent({ | ||
| 1751 | type: "closed", | ||
| 1752 | threadId, | ||
| 1753 | terminalId, | ||
| 1754 | sequence: closedEventSequence, | ||
| 1755 | }); | ||
| 1756 | } | ||
| 1757 | |||
| 1545 | if (deleteHistoryOnClose) { | 1758 | if (deleteHistoryOnClose) { |
| 1546 | yield* deleteHistory(threadId, terminalId); | 1759 | yield* deleteHistory(threadId, terminalId); |
| 1547 | } | 1760 | } |
| ... | @@ -1562,7 +1775,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1562,7 +1775,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1562 | session: TerminalSessionState & { pid: number }, | 1775 | session: TerminalSessionState & { pid: number }, |
| 1563 | ) { | 1776 | ) { |
| 1564 | const terminalPid = session.pid; | 1777 | const terminalPid = session.pid; |
| 1565 | const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe( | 1778 | const inspectResult = yield* subprocessInspector(terminalPid).pipe( |
| 1566 | Effect.map(Option.some), | 1779 | Effect.map(Option.some), |
| 1567 | Effect.catch((reason) => | 1780 | Effect.catch((reason) => |
| 1568 | Effect.logWarning("failed to check terminal subprocess activity", { | 1781 | Effect.logWarning("failed to check terminal subprocess activity", { |
| ... | @@ -1570,16 +1783,16 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1570,16 +1783,16 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1570 | terminalId: session.terminalId, | 1783 | terminalId: session.terminalId, |
| 1571 | terminalPid, | 1784 | terminalPid, |
| 1572 | reason, | 1785 | reason, |
| 1573 | }).pipe(Effect.as(Option.none<boolean>())), | 1786 | }).pipe(Effect.as(Option.none<TerminalSubprocessInspectResult>())), |
| 1574 | ), | 1787 | ), |
| 1575 | ); | 1788 | ); |
| 1576 | 1789 | ||
| 1577 | if (Option.isNone(hasRunningSubprocess)) { | 1790 | if (Option.isNone(inspectResult)) { |
| 1578 | return; | 1791 | return; |
| 1579 | } | 1792 | } |
| 1580 | 1793 | ||
| 1581 | const updatedAt = yield* nowIso; | 1794 | const next = inspectResult.value; |
| 1582 | const createdAt = yield* nowIso; | 1795 | const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; |
| 1583 | const event = yield* modifyManagerState((state) => { | 1796 | const event = yield* modifyManagerState((state) => { |
| 1584 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( | 1797 | const liveSession: Option.Option<TerminalSessionState> = Option.fromNullishOr( |
| 1585 | state.sessions.get(toSessionKey(session.threadId, session.terminalId)), | 1798 | state.sessions.get(toSessionKey(session.threadId, session.terminalId)), |
| ... | @@ -1588,21 +1801,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1588,21 +1801,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1588 | Option.isNone(liveSession) || | 1801 | Option.isNone(liveSession) || |
| 1589 | liveSession.value.status !== "running" || | 1802 | liveSession.value.status !== "running" || |
| 1590 | liveSession.value.pid !== terminalPid || | 1803 | liveSession.value.pid !== terminalPid || |
| 1591 | liveSession.value.hasRunningSubprocess === hasRunningSubprocess.value | 1804 | (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && |
| 1805 | liveSession.value.childCommandLabel === nextChildLabel) | ||
| 1592 | ) { | 1806 | ) { |
| 1593 | return [Option.none(), state] as const; | 1807 | return [Option.none(), state] as const; |
| 1594 | } | 1808 | } |
| 1595 | 1809 | ||
| 1596 | liveSession.value.hasRunningSubprocess = hasRunningSubprocess.value; | 1810 | liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; |
| 1597 | liveSession.value.updatedAt = updatedAt; | 1811 | liveSession.value.childCommandLabel = nextChildLabel; |
| 1812 | const eventStamp = advanceEventSequence(liveSession.value); | ||
| 1598 | 1813 | ||
| 1599 | return [ | 1814 | return [ |
| 1600 | Option.some({ | 1815 | Option.some({ |
| 1601 | type: "activity" as const, | 1816 | type: "activity" as const, |
| 1602 | threadId: liveSession.value.threadId, | 1817 | threadId: liveSession.value.threadId, |
| 1603 | terminalId: liveSession.value.terminalId, | 1818 | terminalId: liveSession.value.terminalId, |
| 1604 | createdAt, | 1819 | sequence: eventStamp.sequence, |
| 1605 | hasRunningSubprocess: hasRunningSubprocess.value, | 1820 | hasRunningSubprocess: next.hasRunningSubprocess, |
| 1821 | label: terminalWireLabel(liveSession.value), | ||
| 1606 | }), | 1822 | }), |
| 1607 | state, | 1823 | state, |
| 1608 | ] as const; | 1824 | ] as const; |
| ... | @@ -1666,136 +1882,352 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1666,136 +1882,352 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1666 | }).pipe(Effect.ignoreCause({ log: true })), | 1882 | }).pipe(Effect.ignoreCause({ log: true })), |
| 1667 | ); | 1883 | ); |
| 1668 | 1884 | ||
| 1885 | const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { | ||
| 1886 | const terminalId = input.terminalId; | ||
| 1887 | yield* assertValidCwd(input.cwd); | ||
| 1888 | |||
| 1889 | const sessionKey = toSessionKey(input.threadId, terminalId); | ||
| 1890 | const existing = yield* getSession(input.threadId, terminalId); | ||
| 1891 | if (Option.isNone(existing)) { | ||
| 1892 | yield* flushPersist(input.threadId, terminalId); | ||
| 1893 | const history = yield* readHistory(input.threadId, terminalId); | ||
| 1894 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | ||
| 1895 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | ||
| 1896 | const session: TerminalSessionState = { | ||
| 1897 | threadId: input.threadId, | ||
| 1898 | terminalId, | ||
| 1899 | cwd: input.cwd, | ||
| 1900 | worktreePath: input.worktreePath ?? null, | ||
| 1901 | status: "starting", | ||
| 1902 | pid: null, | ||
| 1903 | history, | ||
| 1904 | pendingHistoryControlSequence: "", | ||
| 1905 | pendingProcessEvents: [], | ||
| 1906 | pendingProcessEventIndex: 0, | ||
| 1907 | processEventDrainRunning: false, | ||
| 1908 | exitCode: null, | ||
| 1909 | exitSignal: null, | ||
| 1910 | updatedAt: yield* nowIso, | ||
| 1911 | eventSequence: 0, | ||
| 1912 | cols, | ||
| 1913 | rows, | ||
| 1914 | process: null, | ||
| 1915 | unsubscribeData: null, | ||
| 1916 | unsubscribeExit: null, | ||
| 1917 | hasRunningSubprocess: false, | ||
| 1918 | childCommandLabel: null, | ||
| 1919 | runtimeEnv: normalizedRuntimeEnv(input.env), | ||
| 1920 | }; | ||
| 1921 | |||
| 1922 | const createdSession = session; | ||
| 1923 | yield* modifyManagerState((state) => { | ||
| 1924 | const sessions = new Map(state.sessions); | ||
| 1925 | sessions.set(sessionKey, createdSession); | ||
| 1926 | return [undefined, { ...state, sessions }] as const; | ||
| 1927 | }); | ||
| 1928 | |||
| 1929 | yield* evictInactiveSessionsIfNeeded(); | ||
| 1930 | yield* startSession( | ||
| 1931 | session, | ||
| 1932 | { | ||
| 1933 | threadId: input.threadId, | ||
| 1934 | terminalId, | ||
| 1935 | cwd: input.cwd, | ||
| 1936 | ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), | ||
| 1937 | cols, | ||
| 1938 | rows, | ||
| 1939 | ...(input.env ? { env: input.env } : {}), | ||
| 1940 | }, | ||
| 1941 | "started", | ||
| 1942 | ); | ||
| 1943 | return snapshot(session); | ||
| 1944 | } | ||
| 1945 | |||
| 1946 | const liveSession = existing.value; | ||
| 1947 | const nextRuntimeEnv = normalizedRuntimeEnv(input.env); | ||
| 1948 | const currentRuntimeEnv = liveSession.runtimeEnv; | ||
| 1949 | const targetCols = input.cols ?? liveSession.cols; | ||
| 1950 | const targetRows = input.rows ?? liveSession.rows; | ||
| 1951 | const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); | ||
| 1952 | const nextWorktreePath = | ||
| 1953 | input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath; | ||
| 1954 | const launchContextChanged = | ||
| 1955 | liveSession.cwd !== input.cwd || | ||
| 1956 | runtimeEnvChanged || | ||
| 1957 | liveSession.worktreePath !== nextWorktreePath; | ||
| 1958 | |||
| 1959 | if (launchContextChanged) { | ||
| 1960 | yield* stopProcess(liveSession); | ||
| 1961 | liveSession.cwd = input.cwd; | ||
| 1962 | liveSession.worktreePath = nextWorktreePath; | ||
| 1963 | liveSession.runtimeEnv = nextRuntimeEnv; | ||
| 1964 | liveSession.history = ""; | ||
| 1965 | liveSession.pendingHistoryControlSequence = ""; | ||
| 1966 | liveSession.pendingProcessEvents = []; | ||
| 1967 | liveSession.pendingProcessEventIndex = 0; | ||
| 1968 | liveSession.processEventDrainRunning = false; | ||
| 1969 | yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); | ||
| 1970 | } else if (liveSession.status === "exited" || liveSession.status === "error") { | ||
| 1971 | liveSession.runtimeEnv = nextRuntimeEnv; | ||
| 1972 | liveSession.worktreePath = nextWorktreePath; | ||
| 1973 | liveSession.history = ""; | ||
| 1974 | liveSession.pendingHistoryControlSequence = ""; | ||
| 1975 | liveSession.pendingProcessEvents = []; | ||
| 1976 | liveSession.pendingProcessEventIndex = 0; | ||
| 1977 | liveSession.processEventDrainRunning = false; | ||
| 1978 | yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); | ||
| 1979 | } | ||
| 1980 | |||
| 1981 | if (!liveSession.process) { | ||
| 1982 | yield* startSession( | ||
| 1983 | liveSession, | ||
| 1984 | { | ||
| 1985 | threadId: input.threadId, | ||
| 1986 | terminalId, | ||
| 1987 | cwd: input.cwd, | ||
| 1988 | worktreePath: liveSession.worktreePath, | ||
| 1989 | cols: targetCols, | ||
| 1990 | rows: targetRows, | ||
| 1991 | ...(input.env ? { env: input.env } : {}), | ||
| 1992 | }, | ||
| 1993 | "started", | ||
| 1994 | ); | ||
| 1995 | return snapshot(liveSession); | ||
| 1996 | } | ||
| 1997 | |||
| 1998 | if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { | ||
| 1999 | liveSession.cols = targetCols; | ||
| 2000 | liveSession.rows = targetRows; | ||
| 2001 | liveSession.updatedAt = yield* nowIso; | ||
| 2002 | liveSession.process.resize(targetCols, targetRows); | ||
| 2003 | } | ||
| 2004 | |||
| 2005 | return snapshot(liveSession); | ||
| 2006 | }); | ||
| 2007 | |||
| 1669 | const open: TerminalManagerShape["open"] = (input) => | 2008 | const open: TerminalManagerShape["open"] = (input) => |
| 2009 | withThreadLock(input.threadId, openLocked(input)); | ||
| 2010 | |||
| 2011 | const openOrAttachForStream = (input: TerminalAttachInput) => | ||
| 1670 | withThreadLock( | 2012 | withThreadLock( |
| 1671 | input.threadId, | 2013 | input.threadId, |
| 1672 | Effect.gen(function* () { | 2014 | Effect.gen(function* () { |
| 1673 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 2015 | const terminalId = input.terminalId; |
| 1674 | yield* assertValidCwd(input.cwd); | ||
| 1675 | |||
| 1676 | const sessionKey = toSessionKey(input.threadId, terminalId); | ||
| 1677 | const existing = yield* getSession(input.threadId, terminalId); | 2016 | const existing = yield* getSession(input.threadId, terminalId); |
| 2017 | |||
| 1678 | if (Option.isNone(existing)) { | 2018 | if (Option.isNone(existing)) { |
| 1679 | yield* flushPersist(input.threadId, terminalId); | 2019 | if (!input.cwd) { |
| 1680 | const history = yield* readHistory(input.threadId, terminalId); | 2020 | return yield* new TerminalSessionLookupError({ |
| 1681 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | 2021 | threadId: input.threadId, |
| 1682 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | 2022 | terminalId, |
| 1683 | const createdAt = yield* nowIso; | 2023 | }); |
| 1684 | const session: TerminalSessionState = { | 2024 | } |
| 1685 | threadId: input.threadId, | 2025 | |
| 2026 | return yield* openLocked({ | ||
| 2027 | ...input, | ||
| 1686 | terminalId, | 2028 | terminalId, |
| 1687 | cwd: input.cwd, | 2029 | cwd: input.cwd, |
| 1688 | worktreePath: input.worktreePath ?? null, | 2030 | }); |
| 1689 | status: "starting", | 2031 | } |
| 1690 | pid: null, | ||
| 1691 | history, | ||
| 1692 | pendingHistoryControlSequence: "", | ||
| 1693 | pendingProcessEvents: [], | ||
| 1694 | pendingProcessEventIndex: 0, | ||
| 1695 | processEventDrainRunning: false, | ||
| 1696 | exitCode: null, | ||
| 1697 | exitSignal: null, | ||
| 1698 | updatedAt: createdAt, | ||
| 1699 | cols, | ||
| 1700 | rows, | ||
| 1701 | process: null, | ||
| 1702 | unsubscribeData: null, | ||
| 1703 | unsubscribeExit: null, | ||
| 1704 | hasRunningSubprocess: false, | ||
| 1705 | runtimeEnv: normalizedRuntimeEnv(input.env), | ||
| 1706 | }; | ||
| 1707 | 2032 | ||
| 1708 | const createdSession = session; | 2033 | const session = existing.value; |
| 1709 | yield* modifyManagerState((state) => { | 2034 | const targetCols = input.cols ?? session.cols; |
| 1710 | const sessions = new Map(state.sessions); | 2035 | const targetRows = input.rows ?? session.rows; |
| 1711 | sessions.set(sessionKey, createdSession); | 2036 | |
| 1712 | return [undefined, { ...state, sessions }] as const; | 2037 | if (!session.process && input.cwd && input.restartIfNotRunning === true) { |
| 2038 | return yield* openLocked({ | ||
| 2039 | ...input, | ||
| 2040 | terminalId, | ||
| 2041 | cwd: input.cwd, | ||
| 1713 | }); | 2042 | }); |
| 2043 | } | ||
| 1714 | 2044 | ||
| 1715 | yield* evictInactiveSessionsIfNeeded(); | 2045 | if ( |
| 1716 | yield* startSession( | 2046 | session.process && |
| 1717 | session, | 2047 | session.status === "running" && |
| 1718 | { | 2048 | (session.cols !== targetCols || session.rows !== targetRows) |
| 1719 | threadId: input.threadId, | 2049 | ) { |
| 1720 | terminalId, | 2050 | session.cols = targetCols; |
| 1721 | cwd: input.cwd, | 2051 | session.rows = targetRows; |
| 1722 | ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), | 2052 | session.updatedAt = yield* nowIso; |
| 1723 | cols, | 2053 | yield* Effect.sync(() => session.process?.resize(targetCols, targetRows)); |
| 1724 | rows, | ||
| 1725 | ...(input.env ? { env: input.env } : {}), | ||
| 1726 | }, | ||
| 1727 | "started", | ||
| 1728 | ); | ||
| 1729 | return snapshot(session); | ||
| 1730 | } | 2054 | } |
| 1731 | 2055 | ||
| 1732 | const liveSession = existing.value; | 2056 | return snapshot(session); |
| 1733 | const nextRuntimeEnv = normalizedRuntimeEnv(input.env); | 2057 | }), |
| 1734 | const currentRuntimeEnv = liveSession.runtimeEnv; | 2058 | ); |
| 1735 | const targetCols = input.cols ?? liveSession.cols; | 2059 | |
| 1736 | const targetRows = input.rows ?? liveSession.rows; | 2060 | const readAllTerminalMetadata = () => |
| 1737 | const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); | 2061 | readManagerState.pipe( |
| 1738 | 2062 | Effect.map((state) => | |
| 1739 | if (liveSession.cwd !== input.cwd || runtimeEnvChanged) { | 2063 | [...state.sessions.values()] |
| 1740 | yield* stopProcess(liveSession); | 2064 | .map(summary) |
| 1741 | liveSession.cwd = input.cwd; | 2065 | .sort( |
| 1742 | liveSession.worktreePath = input.worktreePath ?? null; | 2066 | (left, right) => |
| 1743 | liveSession.runtimeEnv = nextRuntimeEnv; | 2067 | right.updatedAt.localeCompare(left.updatedAt) || |
| 1744 | liveSession.history = ""; | 2068 | left.threadId.localeCompare(right.threadId) || |
| 1745 | liveSession.pendingHistoryControlSequence = ""; | 2069 | left.terminalId.localeCompare(right.terminalId), |
| 1746 | liveSession.pendingProcessEvents = []; | 2070 | ), |
| 1747 | liveSession.pendingProcessEventIndex = 0; | 2071 | ), |
| 1748 | liveSession.processEventDrainRunning = false; | 2072 | ); |
| 1749 | yield* persistHistory( | 2073 | |
| 1750 | liveSession.threadId, | 2074 | const readTerminalMetadata = (input: { |
| 1751 | liveSession.terminalId, | 2075 | readonly threadId: string; |
| 1752 | liveSession.history, | 2076 | readonly terminalId: string; |
| 1753 | ); | 2077 | }) => |
| 1754 | } else if (liveSession.status === "exited" || liveSession.status === "error") { | 2078 | getSession(input.threadId, input.terminalId).pipe( |
| 1755 | liveSession.runtimeEnv = nextRuntimeEnv; | 2079 | Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), |
| 1756 | liveSession.worktreePath = input.worktreePath ?? null; | 2080 | ); |
| 1757 | liveSession.history = ""; | 2081 | |
| 1758 | liveSession.pendingHistoryControlSequence = ""; | 2082 | const subscribe: TerminalManagerShape["subscribe"] = (listener) => |
| 1759 | liveSession.pendingProcessEvents = []; | 2083 | Effect.sync(() => { |
| 1760 | liveSession.pendingProcessEventIndex = 0; | 2084 | terminalEventListeners.add(listener); |
| 1761 | liveSession.processEventDrainRunning = false; | 2085 | return () => { |
| 1762 | yield* persistHistory( | 2086 | terminalEventListeners.delete(listener); |
| 1763 | liveSession.threadId, | 2087 | }; |
| 1764 | liveSession.terminalId, | 2088 | }); |
| 1765 | liveSession.history, | 2089 | |
| 1766 | ); | 2090 | const attachStream: TerminalManagerShape["attachStream"] = (input, listener) => { |
| 2091 | let unsubscribe: (() => void) | null = null; | ||
| 2092 | |||
| 2093 | return Effect.gen(function* () { | ||
| 2094 | const bufferedEvents: TerminalEvent[] = []; | ||
| 2095 | let deliverLive = false; | ||
| 2096 | |||
| 2097 | unsubscribe = yield* subscribe((event) => { | ||
| 2098 | if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { | ||
| 2099 | return Effect.void; | ||
| 1767 | } | 2100 | } |
| 1768 | 2101 | ||
| 1769 | if (!liveSession.process) { | 2102 | if (!deliverLive) { |
| 1770 | yield* startSession( | 2103 | bufferedEvents.push(event); |
| 1771 | liveSession, | 2104 | return Effect.void; |
| 1772 | { | 2105 | } |
| 1773 | threadId: input.threadId, | 2106 | |
| 1774 | terminalId, | 2107 | const attachEvent = terminalEventToAttachEvent(event); |
| 1775 | cwd: input.cwd, | 2108 | return attachEvent ? listener(attachEvent) : Effect.void; |
| 1776 | worktreePath: liveSession.worktreePath, | 2109 | }); |
| 1777 | cols: targetCols, | 2110 | |
| 1778 | rows: targetRows, | 2111 | const initialSnapshot = yield* openOrAttachForStream(input); |
| 1779 | ...(input.env ? { env: input.env } : {}), | 2112 | |
| 1780 | }, | 2113 | yield* listener({ |
| 1781 | "started", | 2114 | type: "snapshot", |
| 1782 | ); | 2115 | snapshot: initialSnapshot, |
| 1783 | return snapshot(liveSession); | 2116 | }); |
| 2117 | |||
| 2118 | for (const event of bufferedEvents) { | ||
| 2119 | if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { | ||
| 2120 | continue; | ||
| 1784 | } | 2121 | } |
| 1785 | 2122 | ||
| 1786 | if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { | 2123 | const attachEvent = terminalEventToAttachEvent(event); |
| 1787 | liveSession.cols = targetCols; | 2124 | if (attachEvent) { |
| 1788 | liveSession.rows = targetRows; | 2125 | yield* listener(attachEvent); |
| 1789 | liveSession.updatedAt = yield* nowIso; | ||
| 1790 | liveSession.process.resize(targetCols, targetRows); | ||
| 1791 | } | 2126 | } |
| 2127 | } | ||
| 1792 | 2128 | ||
| 1793 | return snapshot(liveSession); | 2129 | deliverLive = true; |
| 1794 | }), | 2130 | return () => { |
| 2131 | unsubscribe?.(); | ||
| 2132 | unsubscribe = null; | ||
| 2133 | }; | ||
| 2134 | }).pipe( | ||
| 2135 | Effect.catchCause((cause) => | ||
| 2136 | Effect.flatMap( | ||
| 2137 | Effect.sync(() => { | ||
| 2138 | unsubscribe?.(); | ||
| 2139 | unsubscribe = null; | ||
| 2140 | }), | ||
| 2141 | () => Effect.failCause(cause), | ||
| 2142 | ), | ||
| 2143 | ), | ||
| 2144 | ); | ||
| 2145 | }; | ||
| 2146 | |||
| 2147 | const metadataEventFromTerminalEvent = ( | ||
| 2148 | event: TerminalEvent, | ||
| 2149 | ): Effect.Effect<TerminalMetadataStreamEvent | null> => { | ||
| 2150 | if (!shouldPublishTerminalMetadataEvent(event)) { | ||
| 2151 | return Effect.succeed(null); | ||
| 2152 | } | ||
| 2153 | |||
| 2154 | if (event.type === "closed") { | ||
| 2155 | return Effect.succeed({ | ||
| 2156 | type: "remove" as const, | ||
| 2157 | threadId: event.threadId, | ||
| 2158 | terminalId: event.terminalId, | ||
| 2159 | }); | ||
| 2160 | } | ||
| 2161 | |||
| 2162 | return readTerminalMetadata({ | ||
| 2163 | threadId: event.threadId, | ||
| 2164 | terminalId: event.terminalId, | ||
| 2165 | }).pipe( | ||
| 2166 | Effect.map((terminal) => | ||
| 2167 | terminal | ||
| 2168 | ? { | ||
| 2169 | type: "upsert" as const, | ||
| 2170 | terminal, | ||
| 2171 | } | ||
| 2172 | : null, | ||
| 2173 | ), | ||
| 2174 | ); | ||
| 2175 | }; | ||
| 2176 | |||
| 2177 | const offerMetadataEvent = ( | ||
| 2178 | listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>, | ||
| 2179 | event: TerminalEvent, | ||
| 2180 | ) => | ||
| 2181 | metadataEventFromTerminalEvent(event).pipe( | ||
| 2182 | Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)), | ||
| 2183 | ); | ||
| 2184 | |||
| 2185 | const subscribeMetadata: TerminalManagerShape["subscribeMetadata"] = (listener) => { | ||
| 2186 | let unsubscribe: (() => void) | null = null; | ||
| 2187 | |||
| 2188 | return Effect.gen(function* () { | ||
| 2189 | const bufferedEvents: TerminalEvent[] = []; | ||
| 2190 | let deliverLive = false; | ||
| 2191 | |||
| 2192 | unsubscribe = yield* subscribe((event) => { | ||
| 2193 | if (!deliverLive) { | ||
| 2194 | bufferedEvents.push(event); | ||
| 2195 | return Effect.void; | ||
| 2196 | } | ||
| 2197 | |||
| 2198 | return offerMetadataEvent(listener, event); | ||
| 2199 | }); | ||
| 2200 | |||
| 2201 | const terminals = yield* readAllTerminalMetadata(); | ||
| 2202 | yield* listener({ | ||
| 2203 | type: "snapshot", | ||
| 2204 | terminals, | ||
| 2205 | }); | ||
| 2206 | |||
| 2207 | for (const event of bufferedEvents) { | ||
| 2208 | yield* offerMetadataEvent(listener, event); | ||
| 2209 | } | ||
| 2210 | |||
| 2211 | deliverLive = true; | ||
| 2212 | return () => { | ||
| 2213 | unsubscribe?.(); | ||
| 2214 | unsubscribe = null; | ||
| 2215 | }; | ||
| 2216 | }).pipe( | ||
| 2217 | Effect.catchCause((cause) => | ||
| 2218 | Effect.flatMap( | ||
| 2219 | Effect.sync(() => { | ||
| 2220 | unsubscribe?.(); | ||
| 2221 | unsubscribe = null; | ||
| 2222 | }), | ||
| 2223 | () => Effect.failCause(cause), | ||
| 2224 | ), | ||
| 2225 | ), | ||
| 1795 | ); | 2226 | ); |
| 2227 | }; | ||
| 1796 | 2228 | ||
| 1797 | const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) { | 2229 | const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) { |
| 1798 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 2230 | const terminalId = input.terminalId; |
| 1799 | const session = yield* requireSession(input.threadId, terminalId); | 2231 | const session = yield* requireSession(input.threadId, terminalId); |
| 1800 | const process = session.process; | 2232 | const process = session.process; |
| 1801 | if (!process || session.status !== "running") { | 2233 | if (!process || session.status !== "running") { |
| ... | @@ -1809,7 +2241,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1809,7 +2241,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1809 | }); | 2241 | }); |
| 1810 | 2242 | ||
| 1811 | const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) { | 2243 | const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) { |
| 1812 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 2244 | const terminalId = input.terminalId; |
| 1813 | const session = yield* requireSession(input.threadId, terminalId); | 2245 | const session = yield* requireSession(input.threadId, terminalId); |
| 1814 | const process = session.process; | 2246 | const process = session.process; |
| 1815 | if (!process || session.status !== "running") { | 2247 | if (!process || session.status !== "running") { |
| ... | @@ -1828,22 +2260,20 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1828,22 +2260,20 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1828 | withThreadLock( | 2260 | withThreadLock( |
| 1829 | input.threadId, | 2261 | input.threadId, |
| 1830 | Effect.gen(function* () { | 2262 | Effect.gen(function* () { |
| 1831 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 2263 | const terminalId = input.terminalId; |
| 1832 | const session = yield* requireSession(input.threadId, terminalId); | 2264 | const session = yield* requireSession(input.threadId, terminalId); |
| 1833 | const updatedAt = yield* nowIso; | ||
| 1834 | session.history = ""; | 2265 | session.history = ""; |
| 1835 | session.pendingHistoryControlSequence = ""; | 2266 | session.pendingHistoryControlSequence = ""; |
| 1836 | session.pendingProcessEvents = []; | 2267 | session.pendingProcessEvents = []; |
| 1837 | session.pendingProcessEventIndex = 0; | 2268 | session.pendingProcessEventIndex = 0; |
| 1838 | session.processEventDrainRunning = false; | 2269 | session.processEventDrainRunning = false; |
| 1839 | session.updatedAt = updatedAt; | 2270 | const eventStamp = advanceEventSequence(session); |
| 1840 | yield* persistHistory(input.threadId, terminalId, session.history); | 2271 | yield* persistHistory(input.threadId, terminalId, session.history); |
| 1841 | const createdAt = yield* nowIso; | ||
| 1842 | yield* publishEvent({ | 2272 | yield* publishEvent({ |
| 1843 | type: "cleared", | 2273 | type: "cleared", |
| 1844 | threadId: input.threadId, | 2274 | threadId: input.threadId, |
| 1845 | terminalId, | 2275 | terminalId, |
| 1846 | createdAt, | 2276 | sequence: eventStamp.sequence, |
| 1847 | }); | 2277 | }); |
| 1848 | }), | 2278 | }), |
| 1849 | ); | 2279 | ); |
| ... | @@ -1853,7 +2283,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1853,7 +2283,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1853 | input.threadId, | 2283 | input.threadId, |
| 1854 | Effect.gen(function* () { | 2284 | Effect.gen(function* () { |
| 1855 | yield* increment(terminalRestartsTotal, { scope: "thread" }); | 2285 | yield* increment(terminalRestartsTotal, { scope: "thread" }); |
| 1856 | const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; | 2286 | const terminalId = input.terminalId; |
| 1857 | yield* assertValidCwd(input.cwd); | 2287 | yield* assertValidCwd(input.cwd); |
| 1858 | 2288 | ||
| 1859 | const sessionKey = toSessionKey(input.threadId, terminalId); | 2289 | const sessionKey = toSessionKey(input.threadId, terminalId); |
| ... | @@ -1862,7 +2292,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1862,7 +2292,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1862 | if (Option.isNone(existingSession)) { | 2292 | if (Option.isNone(existingSession)) { |
| 1863 | const cols = input.cols ?? DEFAULT_OPEN_COLS; | 2293 | const cols = input.cols ?? DEFAULT_OPEN_COLS; |
| 1864 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; | 2294 | const rows = input.rows ?? DEFAULT_OPEN_ROWS; |
| 1865 | const createdAt = yield* nowIso; | ||
| 1866 | session = { | 2295 | session = { |
| 1867 | threadId: input.threadId, | 2296 | threadId: input.threadId, |
| 1868 | terminalId, | 2297 | terminalId, |
| ... | @@ -1877,13 +2306,15 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1877,13 +2306,15 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1877 | processEventDrainRunning: false, | 2306 | processEventDrainRunning: false, |
| 1878 | exitCode: null, | 2307 | exitCode: null, |
| 1879 | exitSignal: null, | 2308 | exitSignal: null, |
| 1880 | updatedAt: createdAt, | 2309 | updatedAt: yield* nowIso, |
| 2310 | eventSequence: 0, | ||
| 1881 | cols, | 2311 | cols, |
| 1882 | rows, | 2312 | rows, |
| 1883 | process: null, | 2313 | process: null, |
| 1884 | unsubscribeData: null, | 2314 | unsubscribeData: null, |
| 1885 | unsubscribeExit: null, | 2315 | unsubscribeExit: null, |
| 1886 | hasRunningSubprocess: false, | 2316 | hasRunningSubprocess: false, |
| 2317 | childCommandLabel: null, | ||
| 1887 | runtimeEnv: normalizedRuntimeEnv(input.env), | 2318 | runtimeEnv: normalizedRuntimeEnv(input.env), |
| 1888 | }; | 2319 | }; |
| 1889 | const createdSession = session; | 2320 | const createdSession = session; |
| ... | @@ -1951,18 +2382,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith | ... | @@ -1951,18 +2382,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith |
| 1951 | 2382 | ||
| 1952 | return { | 2383 | return { |
| 1953 | open, | 2384 | open, |
| 2385 | attachStream, | ||
| 1954 | write, | 2386 | write, |
| 1955 | resize, | 2387 | resize, |
| 1956 | clear, | 2388 | clear, |
| 1957 | restart, | 2389 | restart, |
| 1958 | close, | 2390 | close, |
| 1959 | subscribe: (listener) => | 2391 | subscribe, |
| 1960 | Effect.sync(() => { | 2392 | subscribeMetadata, |
| 1961 | terminalEventListeners.add(listener); | ||
| 1962 | return () => { | ||
| 1963 | terminalEventListeners.delete(listener); | ||
| 1964 | }; | ||
| 1965 | }), | ||
| 1966 | } satisfies TerminalManagerShape; | 2393 | } satisfies TerminalManagerShape; |
| 1967 | }, | 2394 | }, |
| 1968 | ); | 2395 | ); |
apps/server/src/terminal/Services/Manager.ts+22| ... | @@ -7,12 +7,15 @@ | ... | @@ -7,12 +7,15 @@ |
| 7 | * @module TerminalManager | 7 | * @module TerminalManager |
| 8 | */ | 8 | */ |
| 9 | import { | 9 | import { |
| 10 | TerminalAttachInput, | ||
| 11 | TerminalAttachStreamEvent, | ||
| 10 | TerminalClearInput, | 12 | TerminalClearInput, |
| 11 | TerminalCloseInput, | 13 | TerminalCloseInput, |
| 12 | TerminalEvent, | 14 | TerminalEvent, |
| 13 | TerminalCwdError, | 15 | TerminalCwdError, |
| 14 | TerminalError, | 16 | TerminalError, |
| 15 | TerminalHistoryError, | 17 | TerminalHistoryError, |
| 18 | TerminalMetadataStreamEvent, | ||
| 16 | TerminalNotRunningError, | 19 | TerminalNotRunningError, |
| 17 | TerminalOpenInput, | 20 | TerminalOpenInput, |
| 18 | TerminalResizeInput, | 21 | TerminalResizeInput, |
| ... | @@ -79,6 +82,16 @@ export interface TerminalManagerShape { | ... | @@ -79,6 +82,16 @@ export interface TerminalManagerShape { |
| 79 | input: TerminalOpenInput, | 82 | input: TerminalOpenInput, |
| 80 | ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>; | 83 | ) => Effect.Effect<TerminalSessionSnapshot, TerminalError>; |
| 81 | 84 | ||
| 85 | /** | ||
| 86 | * Attach to a terminal and stream its initial snapshot followed by live events. | ||
| 87 | * | ||
| 88 | * Returns an unsubscribe function. | ||
| 89 | */ | ||
| 90 | readonly attachStream: ( | ||
| 91 | input: TerminalAttachInput, | ||
| 92 | listener: (event: TerminalAttachStreamEvent) => Effect.Effect<void>, | ||
| 93 | ) => Effect.Effect<() => void, TerminalError>; | ||
| 94 | |||
| 82 | /** | 95 | /** |
| 83 | * Write input bytes to a terminal session. | 96 | * Write input bytes to a terminal session. |
| 84 | */ | 97 | */ |
| ... | @@ -118,6 +131,15 @@ export interface TerminalManagerShape { | ... | @@ -118,6 +131,15 @@ export interface TerminalManagerShape { |
| 118 | readonly subscribe: ( | 131 | readonly subscribe: ( |
| 119 | listener: (event: TerminalEvent) => Effect.Effect<void>, | 132 | listener: (event: TerminalEvent) => Effect.Effect<void>, |
| 120 | ) => Effect.Effect<() => void>; | 133 | ) => Effect.Effect<() => void>; |
| 134 | |||
| 135 | /** | ||
| 136 | * Subscribe to lightweight terminal metadata with an initial full snapshot. | ||
| 137 | * | ||
| 138 | * Returns an unsubscribe function. | ||
| 139 | */ | ||
| 140 | readonly subscribeMetadata: ( | ||
| 141 | listener: (event: TerminalMetadataStreamEvent) => Effect.Effect<void>, | ||
| 142 | ) => Effect.Effect<() => void>; | ||
| 121 | } | 143 | } |
| 122 | 144 | ||
| 123 | /** | 145 | /** |
apps/server/src/vcs/GitVcsDriver.ts+5| ... | @@ -18,6 +18,8 @@ import { | ... | @@ -18,6 +18,8 @@ import { |
| 18 | type VcsCreateRefResult, | 18 | type VcsCreateRefResult, |
| 19 | type VcsCreateWorktreeInput, | 19 | type VcsCreateWorktreeInput, |
| 20 | type VcsCreateWorktreeResult, | 20 | type VcsCreateWorktreeResult, |
| 21 | type ReviewDiffPreviewInput, | ||
| 22 | type ReviewDiffPreviewResult, | ||
| 21 | type VcsInitInput, | 23 | type VcsInitInput, |
| 22 | type VcsListRefsInput, | 24 | type VcsListRefsInput, |
| 23 | type VcsListRefsResult, | 25 | type VcsListRefsResult, |
| ... | @@ -179,6 +181,9 @@ export interface GitVcsDriverShape { | ... | @@ -179,6 +181,9 @@ export interface GitVcsDriverShape { |
| 179 | cwd: string, | 181 | cwd: string, |
| 180 | baseRef: string, | 182 | baseRef: string, |
| 181 | ) => Effect.Effect<GitRangeContext, GitCommandError>; | 183 | ) => Effect.Effect<GitRangeContext, GitCommandError>; |
| 184 | readonly getReviewDiffPreview: ( | ||
| 185 | input: ReviewDiffPreviewInput, | ||
| 186 | ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>; | ||
| 182 | readonly readConfigValue: ( | 187 | readonly readConfigValue: ( |
| 183 | cwd: string, | 188 | cwd: string, |
| 184 | key: string, | 189 | key: string, |
apps/server/src/vcs/GitVcsDriverCore.test.ts+25| ... | @@ -9,6 +9,7 @@ import * as Scope from "effect/Scope"; | ... | @@ -9,6 +9,7 @@ import * as Scope from "effect/Scope"; |
| 9 | 9 | ||
| 10 | import { GitCommandError } from "@t3tools/contracts"; | 10 | import { GitCommandError } from "@t3tools/contracts"; |
| 11 | import { ServerConfig } from "../config.ts"; | 11 | import { ServerConfig } from "../config.ts"; |
| 12 | import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; | ||
| 12 | import * as GitVcsDriver from "./GitVcsDriver.ts"; | 13 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 13 | 14 | ||
| 14 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { | 15 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| ... | @@ -77,6 +78,30 @@ const initRepoWithCommit = ( | ... | @@ -77,6 +78,30 @@ const initRepoWithCommit = ( |
| 77 | }); | 78 | }); |
| 78 | 79 | ||
| 79 | it.layer(TestLayer)("GitVcsDriver core integration", (it) => { | 80 | it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 81 | describe("review diff previews", () => { | ||
| 82 | it.effect("drops an unterminated path from truncated NUL-separated git output", () => | ||
| 83 | Effect.sync(() => { | ||
| 84 | const paths = splitNullSeparatedGitStdoutPaths({ | ||
| 85 | stdout: "complete.txt\0partial", | ||
| 86 | stdoutTruncated: true, | ||
| 87 | }); | ||
| 88 | |||
| 89 | assert.deepStrictEqual(paths, ["complete.txt"]); | ||
| 90 | }), | ||
| 91 | ); | ||
| 92 | |||
| 93 | it.effect("keeps the final path when NUL-separated git output is complete", () => | ||
| 94 | Effect.sync(() => { | ||
| 95 | const paths = splitNullSeparatedGitStdoutPaths({ | ||
| 96 | stdout: "complete.txt\0final.txt", | ||
| 97 | stdoutTruncated: false, | ||
| 98 | }); | ||
| 99 | |||
| 100 | assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]); | ||
| 101 | }), | ||
| 102 | ); | ||
| 103 | }); | ||
| 104 | |||
| 80 | describe("repository status", () => { | 105 | describe("repository status", () => { |
| 81 | it.effect("reports non-repository directories without failing", () => | 106 | it.effect("reports non-repository directories without failing", () => |
| 82 | Effect.gen(function* () { | 107 | Effect.gen(function* () { |
apps/server/src/vcs/GitVcsDriverCore.ts+189-1| ... | @@ -1,9 +1,11 @@ | ... | @@ -1,9 +1,11 @@ |
| 1 | import * as Arr from "effect/Array"; | 1 | import * as Arr from "effect/Array"; |
| 2 | import * as Cache from "effect/Cache"; | 2 | import * as Cache from "effect/Cache"; |
| 3 | import * as Data from "effect/Data"; | 3 | import * as Data from "effect/Data"; |
| 4 | import * as Crypto from "effect/Crypto"; | ||
| 4 | import * as DateTime from "effect/DateTime"; | 5 | import * as DateTime from "effect/DateTime"; |
| 5 | import * as Duration from "effect/Duration"; | 6 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Encoding from "effect/Encoding"; | ||
| 7 | import * as Exit from "effect/Exit"; | 9 | import * as Exit from "effect/Exit"; |
| 8 | import * as FileSystem from "effect/FileSystem"; | 10 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| ... | @@ -17,7 +19,12 @@ import * as Semaphore from "effect/Semaphore"; | ... | @@ -17,7 +19,12 @@ import * as Semaphore from "effect/Semaphore"; |
| 17 | import * as Stream from "effect/Stream"; | 19 | import * as Stream from "effect/Stream"; |
| 18 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 20 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 19 | 21 | ||
| 20 | import { GitCommandError, type VcsRef } from "@t3tools/contracts"; | 22 | import { |
| 23 | GitCommandError, | ||
| 24 | type ReviewDiffPreviewInput, | ||
| 25 | type ReviewDiffPreviewSource, | ||
| 26 | type VcsRef, | ||
| 27 | } from "@t3tools/contracts"; | ||
| 21 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; | 28 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; |
| 22 | import { compactTraceAttributes } from "@t3tools/shared/observability"; | 29 | import { compactTraceAttributes } from "@t3tools/shared/observability"; |
| 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | 30 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| ... | @@ -38,8 +45,12 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; | ... | @@ -38,8 +45,12 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; |
| 38 | const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; | 45 | const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; |
| 39 | const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; | 46 | const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; |
| 40 | const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; | 47 | const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; |
| 48 | const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; | ||
| 49 | const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000; | ||
| 50 | const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; | ||
| 41 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); | 51 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); |
| 42 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); | 52 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 53 | |||
| 43 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); | 54 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); |
| 44 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; | 55 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| 45 | const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ | 56 | const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ |
| ... | @@ -186,6 +197,23 @@ function paginateBranches(input: { | ... | @@ -186,6 +197,23 @@ function paginateBranches(input: { |
| 186 | }; | 197 | }; |
| 187 | } | 198 | } |
| 188 | 199 | ||
| 200 | function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { | ||
| 201 | const parts = input.split("\0"); | ||
| 202 | if (parts.length === 0) return []; | ||
| 203 | |||
| 204 | if (truncated && parts[parts.length - 1]?.length) { | ||
| 205 | parts.pop(); | ||
| 206 | } | ||
| 207 | |||
| 208 | return parts.filter((value) => value.length > 0); | ||
| 209 | } | ||
| 210 | |||
| 211 | export function splitNullSeparatedGitStdoutPaths( | ||
| 212 | result: Pick<GitVcsDriver.ExecuteGitResult, "stdout" | "stdoutTruncated">, | ||
| 213 | ): string[] { | ||
| 214 | return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated); | ||
| 215 | } | ||
| 216 | |||
| 189 | function sanitizeRemoteName(value: string): string { | 217 | function sanitizeRemoteName(value: string): string { |
| 190 | const sanitized = value | 218 | const sanitized = value |
| 191 | .trim() | 219 | .trim() |
| ... | @@ -611,6 +639,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -611,6 +639,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 611 | const path = yield* Path.Path; | 639 | const path = yield* Path.Path; |
| 612 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 640 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 613 | const { worktreesDir } = yield* ServerConfig; | 641 | const { worktreesDir } = yield* ServerConfig; |
| 642 | const crypto = yield* Crypto.Crypto; | ||
| 614 | 643 | ||
| 615 | const executeRaw: GitVcsDriver.GitVcsDriverShape["execute"] = Effect.fnUntraced( | 644 | const executeRaw: GitVcsDriver.GitVcsDriverShape["execute"] = Effect.fnUntraced( |
| 616 | function* (input) { | 645 | function* (input) { |
| ... | @@ -1631,6 +1660,164 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1631,6 +1660,164 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1631 | }; | 1660 | }; |
| 1632 | }); | 1661 | }); |
| 1633 | 1662 | ||
| 1663 | const readUntrackedReviewDiffs = Effect.fn("readUntrackedReviewDiffs")(function* (cwd: string) { | ||
| 1664 | const untrackedResult = yield* executeGit( | ||
| 1665 | "GitVcsDriver.readUntrackedReviewDiffs.list", | ||
| 1666 | cwd, | ||
| 1667 | ["ls-files", "--others", "--exclude-standard", "-z"], | ||
| 1668 | { | ||
| 1669 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, | ||
| 1670 | appendTruncationMarker: true, | ||
| 1671 | }, | ||
| 1672 | ); | ||
| 1673 | const untrackedPaths = splitNullSeparatedGitStdoutPaths(untrackedResult); | ||
| 1674 | if (untrackedPaths.length === 0) { | ||
| 1675 | return { diff: "", truncated: untrackedResult.stdoutTruncated }; | ||
| 1676 | } | ||
| 1677 | |||
| 1678 | const diffs = yield* Effect.forEach( | ||
| 1679 | untrackedPaths, | ||
| 1680 | (relativePath) => | ||
| 1681 | executeGit( | ||
| 1682 | "GitVcsDriver.readUntrackedReviewDiffs.diff", | ||
| 1683 | cwd, | ||
| 1684 | ["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", relativePath], | ||
| 1685 | { | ||
| 1686 | allowNonZeroExit: true, | ||
| 1687 | maxOutputBytes: REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES, | ||
| 1688 | appendTruncationMarker: true, | ||
| 1689 | }, | ||
| 1690 | ), | ||
| 1691 | { concurrency: 4 }, | ||
| 1692 | ); | ||
| 1693 | |||
| 1694 | return { | ||
| 1695 | diff: Arr.filterMap(diffs, (result) => | ||
| 1696 | result.stdout.trim().length > 0 ? Result.succeed(result.stdout) : Result.failVoid, | ||
| 1697 | ).join("\n"), | ||
| 1698 | truncated: untrackedResult.stdoutTruncated || diffs.some((result) => result.stdoutTruncated), | ||
| 1699 | }; | ||
| 1700 | }); | ||
| 1701 | |||
| 1702 | const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( | ||
| 1703 | input: ReviewDiffPreviewInput, | ||
| 1704 | ) { | ||
| 1705 | const details = yield* statusDetailsLocal(input.cwd); | ||
| 1706 | if (!details.isRepo) { | ||
| 1707 | return { | ||
| 1708 | cwd: input.cwd, | ||
| 1709 | generatedAt: yield* DateTime.now, | ||
| 1710 | sources: [], | ||
| 1711 | }; | ||
| 1712 | } | ||
| 1713 | |||
| 1714 | const branch = details.branch; | ||
| 1715 | const baseRef = | ||
| 1716 | input.baseRef ?? | ||
| 1717 | (branch | ||
| 1718 | ? yield* resolveBaseBranchForNoUpstream(input.cwd, branch).pipe( | ||
| 1719 | Effect.catch(() => Effect.succeed(null)), | ||
| 1720 | ) | ||
| 1721 | : null); | ||
| 1722 | |||
| 1723 | const dirtyTrackedResult = yield* executeGit( | ||
| 1724 | "GitVcsDriver.getReviewDiffPreview.dirtyTracked", | ||
| 1725 | input.cwd, | ||
| 1726 | ["diff", "--patch", "--minimal", "HEAD", "--"], | ||
| 1727 | { | ||
| 1728 | maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, | ||
| 1729 | appendTruncationMarker: true, | ||
| 1730 | }, | ||
| 1731 | ).pipe( | ||
| 1732 | Effect.catch(() => | ||
| 1733 | Effect.succeed({ | ||
| 1734 | exitCode: 0, | ||
| 1735 | stdout: "", | ||
| 1736 | stderr: "", | ||
| 1737 | stdoutTruncated: false, | ||
| 1738 | stderrTruncated: false, | ||
| 1739 | }), | ||
| 1740 | ), | ||
| 1741 | ); | ||
| 1742 | const dirtyUntracked = yield* readUntrackedReviewDiffs(input.cwd).pipe( | ||
| 1743 | Effect.catch(() => Effect.succeed({ diff: "", truncated: false })), | ||
| 1744 | ); | ||
| 1745 | const dirtyDiff = [dirtyTrackedResult.stdout.trimEnd(), dirtyUntracked.diff.trimEnd()] | ||
| 1746 | .filter((diff) => diff.length > 0) | ||
| 1747 | .join("\n"); | ||
| 1748 | |||
| 1749 | const baseResult = | ||
| 1750 | baseRef && branch | ||
| 1751 | ? yield* executeGit( | ||
| 1752 | "GitVcsDriver.getReviewDiffPreview.base", | ||
| 1753 | input.cwd, | ||
| 1754 | ["diff", "--patch", "--minimal", `${baseRef}...HEAD`], | ||
| 1755 | { | ||
| 1756 | maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, | ||
| 1757 | appendTruncationMarker: true, | ||
| 1758 | }, | ||
| 1759 | ).pipe( | ||
| 1760 | Effect.catch(() => | ||
| 1761 | Effect.succeed({ | ||
| 1762 | exitCode: 0, | ||
| 1763 | stdout: "", | ||
| 1764 | stderr: "", | ||
| 1765 | stdoutTruncated: false, | ||
| 1766 | stderrTruncated: false, | ||
| 1767 | }), | ||
| 1768 | ), | ||
| 1769 | ) | ||
| 1770 | : null; | ||
| 1771 | const baseDiff = baseResult?.stdout ?? ""; | ||
| 1772 | const hashDiff = (diff: string) => | ||
| 1773 | crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe( | ||
| 1774 | Effect.map(Encoding.encodeHex), | ||
| 1775 | Effect.mapError( | ||
| 1776 | toGitCommandError( | ||
| 1777 | { | ||
| 1778 | operation: "GitVcsDriver.getReviewDiffPreview.hash", | ||
| 1779 | cwd: input.cwd, | ||
| 1780 | args: [], | ||
| 1781 | }, | ||
| 1782 | "failed to hash review diff.", | ||
| 1783 | ), | ||
| 1784 | ), | ||
| 1785 | ); | ||
| 1786 | const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ | ||
| 1787 | hashDiff(dirtyDiff), | ||
| 1788 | hashDiff(baseDiff), | ||
| 1789 | ]); | ||
| 1790 | |||
| 1791 | const sources: ReviewDiffPreviewSource[] = [ | ||
| 1792 | { | ||
| 1793 | id: "working-tree", | ||
| 1794 | kind: "working-tree", | ||
| 1795 | title: "Dirty worktree", | ||
| 1796 | baseRef: "HEAD", | ||
| 1797 | headRef: null, | ||
| 1798 | diff: dirtyDiff, | ||
| 1799 | diffHash: dirtyDiffHash, | ||
| 1800 | truncated: dirtyTrackedResult.stdoutTruncated || dirtyUntracked.truncated, | ||
| 1801 | }, | ||
| 1802 | { | ||
| 1803 | id: "branch-range", | ||
| 1804 | kind: "branch-range", | ||
| 1805 | title: baseRef ? `Against ${baseRef}` : "Against base branch", | ||
| 1806 | baseRef, | ||
| 1807 | headRef: branch ?? "HEAD", | ||
| 1808 | diff: baseDiff, | ||
| 1809 | diffHash: baseDiffHash, | ||
| 1810 | truncated: baseResult?.stdoutTruncated ?? false, | ||
| 1811 | }, | ||
| 1812 | ]; | ||
| 1813 | |||
| 1814 | return { | ||
| 1815 | cwd: input.cwd, | ||
| 1816 | generatedAt: yield* DateTime.now, | ||
| 1817 | sources, | ||
| 1818 | }; | ||
| 1819 | }); | ||
| 1820 | |||
| 1634 | const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) => | 1821 | const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) => |
| 1635 | runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( | 1822 | runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( |
| 1636 | Effect.map((stdout) => stdout.trim()), | 1823 | Effect.map((stdout) => stdout.trim()), |
| ... | @@ -2128,6 +2315,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -2128,6 +2315,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 2128 | pushCurrentBranch, | 2315 | pushCurrentBranch, |
| 2129 | pullCurrentBranch, | 2316 | pullCurrentBranch, |
| 2130 | readRangeContext, | 2317 | readRangeContext, |
| 2318 | getReviewDiffPreview, | ||
| 2131 | readConfigValue, | 2319 | readConfigValue, |
| 2132 | listRefs, | 2320 | listRefs, |
| 2133 | createWorktree, | 2321 | createWorktree, |
apps/server/src/vcs/VcsDriver.ts+5| ... | @@ -7,6 +7,8 @@ import type { | ... | @@ -7,6 +7,8 @@ import type { |
| 7 | VcsInitInput, | 7 | VcsInitInput, |
| 8 | VcsListRemotesResult, | 8 | VcsListRemotesResult, |
| 9 | VcsListWorkspaceFilesResult, | 9 | VcsListWorkspaceFilesResult, |
| 10 | ReviewDiffPreviewInput, | ||
| 11 | ReviewDiffPreviewResult, | ||
| 10 | VcsRepositoryIdentity, | 12 | VcsRepositoryIdentity, |
| 11 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 12 | import { CheckpointRef } from "@t3tools/contracts"; | 14 | import { CheckpointRef } from "@t3tools/contracts"; |
| ... | @@ -67,6 +69,9 @@ export interface VcsDriverShape { | ... | @@ -67,6 +69,9 @@ export interface VcsDriverShape { |
| 67 | relativePaths: ReadonlyArray<string>, | 69 | relativePaths: ReadonlyArray<string>, |
| 68 | ) => Effect.Effect<ReadonlyArray<string>, VcsError>; | 70 | ) => Effect.Effect<ReadonlyArray<string>, VcsError>; |
| 69 | readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>; | 71 | readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>; |
| 72 | readonly getDiffPreview?: ( | ||
| 73 | input: ReviewDiffPreviewInput, | ||
| 74 | ) => Effect.Effect<ReviewDiffPreviewResult, VcsError>; | ||
| 70 | } | 75 | } |
| 71 | 76 | ||
| 72 | export class VcsDriver extends Context.Service<VcsDriver, VcsDriverShape>()("t3/vcs/VcsDriver") {} | 77 | export class VcsDriver extends Context.Service<VcsDriver, VcsDriverShape>()("t3/vcs/VcsDriver") {} |
apps/server/src/ws.ts+31| ... | @@ -30,7 +30,10 @@ import { | ... | @@ -30,7 +30,10 @@ import { |
| 30 | OrchestrationReplayEventsError, | 30 | OrchestrationReplayEventsError, |
| 31 | FilesystemBrowseError, | 31 | FilesystemBrowseError, |
| 32 | ThreadId, | 32 | ThreadId, |
| 33 | type TerminalAttachStreamEvent, | ||
| 34 | type TerminalError, | ||
| 33 | type TerminalEvent, | 35 | type TerminalEvent, |
| 36 | type TerminalMetadataStreamEvent, | ||
| 34 | WS_METHODS, | 37 | WS_METHODS, |
| 35 | WsRpcGroup, | 38 | WsRpcGroup, |
| 36 | } from "@t3tools/contracts"; | 39 | } from "@t3tools/contracts"; |
| ... | @@ -62,6 +65,7 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat | ... | @@ -62,6 +65,7 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat |
| 62 | import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; | 65 | import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; |
| 63 | import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; | 66 | import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; |
| 64 | import { GitWorkflowService } from "./git/GitWorkflowService.ts"; | 67 | import { GitWorkflowService } from "./git/GitWorkflowService.ts"; |
| 68 | import { ReviewService } from "./review/ReviewService.ts"; | ||
| 65 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; | 69 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; |
| 66 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; | 70 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; |
| 67 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | 71 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; |
| ... | @@ -168,6 +172,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -168,6 +172,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 168 | const keybindings = yield* Keybindings; | 172 | const keybindings = yield* Keybindings; |
| 169 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; | 173 | const externalLauncher = yield* ExternalLauncher.ExternalLauncher; |
| 170 | const gitWorkflow = yield* GitWorkflowService; | 174 | const gitWorkflow = yield* GitWorkflowService; |
| 175 | const review = yield* ReviewService; | ||
| 171 | const vcsProvisioning = yield* VcsProvisioningService; | 176 | const vcsProvisioning = yield* VcsProvisioningService; |
| 172 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; | 177 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 173 | const terminalManager = yield* TerminalManager; | 178 | const terminalManager = yield* TerminalManager; |
| ... | @@ -1119,10 +1124,25 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -1119,10 +1124,25 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 1119 | .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | 1124 | .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 1120 | { "rpc.aggregate": "vcs" }, | 1125 | { "rpc.aggregate": "vcs" }, |
| 1121 | ), | 1126 | ), |
| 1127 | [WS_METHODS.reviewGetDiffPreview]: (input) => | ||
| 1128 | observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), { | ||
| 1129 | "rpc.aggregate": "review", | ||
| 1130 | }), | ||
| 1122 | [WS_METHODS.terminalOpen]: (input) => | 1131 | [WS_METHODS.terminalOpen]: (input) => |
| 1123 | observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { | 1132 | observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { |
| 1124 | "rpc.aggregate": "terminal", | 1133 | "rpc.aggregate": "terminal", |
| 1125 | }), | 1134 | }), |
| 1135 | [WS_METHODS.terminalAttach]: (input) => | ||
| 1136 | observeRpcStream( | ||
| 1137 | WS_METHODS.terminalAttach, | ||
| 1138 | Stream.callback<TerminalAttachStreamEvent, TerminalError>((queue) => | ||
| 1139 | Effect.acquireRelease( | ||
| 1140 | terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), | ||
| 1141 | (unsubscribe) => Effect.sync(unsubscribe), | ||
| 1142 | ), | ||
| 1143 | ), | ||
| 1144 | { "rpc.aggregate": "terminal" }, | ||
| 1145 | ), | ||
| 1126 | [WS_METHODS.terminalWrite]: (input) => | 1146 | [WS_METHODS.terminalWrite]: (input) => |
| 1127 | observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { | 1147 | observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { |
| 1128 | "rpc.aggregate": "terminal", | 1148 | "rpc.aggregate": "terminal", |
| ... | @@ -1154,6 +1174,17 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -1154,6 +1174,17 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 1154 | ), | 1174 | ), |
| 1155 | { "rpc.aggregate": "terminal" }, | 1175 | { "rpc.aggregate": "terminal" }, |
| 1156 | ), | 1176 | ), |
| 1177 | [WS_METHODS.subscribeTerminalMetadata]: (_input) => | ||
| 1178 | observeRpcStream( | ||
| 1179 | WS_METHODS.subscribeTerminalMetadata, | ||
| 1180 | Stream.callback<TerminalMetadataStreamEvent>((queue) => | ||
| 1181 | Effect.acquireRelease( | ||
| 1182 | terminalManager.subscribeMetadata((event) => Queue.offer(queue, event)), | ||
| 1183 | (unsubscribe) => Effect.sync(unsubscribe), | ||
| 1184 | ), | ||
| 1185 | ), | ||
| 1186 | { "rpc.aggregate": "terminal" }, | ||
| 1187 | ), | ||
| 1157 | [WS_METHODS.subscribeServerConfig]: (_input) => | 1188 | [WS_METHODS.subscribeServerConfig]: (_input) => |
| 1158 | observeRpcStreamEffect( | 1189 | observeRpcStreamEffect( |
| 1159 | WS_METHODS.subscribeServerConfig, | 1190 | WS_METHODS.subscribeServerConfig, |
apps/web/src/components/BranchToolbarBranchSelector.tsx+37-37| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; |
| 3 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; | ||
| 4 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | 3 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 5 | import { ChevronDownIcon } from "lucide-react"; | 4 | import { ChevronDownIcon } from "lucide-react"; |
| 6 | import { | 5 | import { |
| ... | @@ -16,8 +15,8 @@ import { | ... | @@ -16,8 +15,8 @@ import { |
| 16 | 15 | ||
| 17 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | 16 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 18 | import { readEnvironmentApi } from "../environmentApi"; | 17 | import { readEnvironmentApi } from "../environmentApi"; |
| 19 | import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; | 18 | import { useVcsStatus } from "../lib/vcsStatusState"; |
| 20 | import { useGitStatus } from "../lib/gitStatusState"; | 19 | import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState"; |
| 21 | import { newCommandId } from "../lib/utils"; | 20 | import { newCommandId } from "../lib/utils"; |
| 22 | import { cn } from "../lib/utils"; | 21 | import { cn } from "../lib/utils"; |
| 23 | import { parsePullRequestReference } from "../pullRequestReference"; | 22 | import { parsePullRequestReference } from "../pullRequestReference"; |
| ... | @@ -59,6 +58,8 @@ interface BranchToolbarBranchSelectorProps { | ... | @@ -59,6 +58,8 @@ interface BranchToolbarBranchSelectorProps { |
| 59 | onComposerFocusRequest?: () => void; | 58 | onComposerFocusRequest?: () => void; |
| 60 | } | 59 | } |
| 61 | 60 | ||
| 61 | const EMPTY_REFS: ReadonlyArray<VcsRef> = []; | ||
| 62 | |||
| 62 | function toBranchActionErrorMessage(error: unknown): string { | 63 | function toBranchActionErrorMessage(error: unknown): string { |
| 63 | return error instanceof Error ? error.message : "An error occurred."; | 64 | return error instanceof Error ? error.message : "An error occurred."; |
| 64 | } | 65 | } |
| ... | @@ -196,39 +197,27 @@ export function BranchToolbarBranchSelector({ | ... | @@ -196,39 +197,27 @@ export function BranchToolbarBranchSelector({ |
| 196 | // --------------------------------------------------------------------------- | 197 | // --------------------------------------------------------------------------- |
| 197 | // Git ref queries | 198 | // Git ref queries |
| 198 | // --------------------------------------------------------------------------- | 199 | // --------------------------------------------------------------------------- |
| 199 | const queryClient = useQueryClient(); | ||
| 200 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); | 200 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 201 | const [branchQuery, setBranchQuery] = useState(""); | 201 | const [branchQuery, setBranchQuery] = useState(""); |
| 202 | const deferredBranchQuery = useDeferredValue(branchQuery); | 202 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 203 | 203 | ||
| 204 | const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd }); | 204 | const branchStatusQuery = useVcsStatus({ environmentId, cwd: branchCwd }); |
| 205 | const trimmedBranchQuery = branchQuery.trim(); | 205 | const trimmedBranchQuery = branchQuery.trim(); |
| 206 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); | 206 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); |
| 207 | 207 | const branchRefTarget = useMemo( | |
| 208 | useEffect(() => { | 208 | () => ({ |
| 209 | if (!branchCwd) return; | ||
| 210 | void queryClient.prefetchInfiniteQuery( | ||
| 211 | gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }), | ||
| 212 | ); | ||
| 213 | }, [branchCwd, environmentId, queryClient]); | ||
| 214 | |||
| 215 | const { | ||
| 216 | data: branchesSearchData, | ||
| 217 | fetchNextPage, | ||
| 218 | hasNextPage, | ||
| 219 | isFetchingNextPage, | ||
| 220 | isPending: isBranchesSearchPending, | ||
| 221 | } = useInfiniteQuery( | ||
| 222 | gitBranchSearchInfiniteQueryOptions({ | ||
| 223 | environmentId, | 209 | environmentId, |
| 224 | cwd: branchCwd, | 210 | cwd: branchCwd, |
| 225 | query: deferredTrimmedBranchQuery, | 211 | query: deferredTrimmedBranchQuery, |
| 226 | }), | 212 | }), |
| 213 | [branchCwd, deferredTrimmedBranchQuery, environmentId], | ||
| 227 | ); | 214 | ); |
| 228 | const refs = useMemo( | 215 | const branchRefState = useVcsRefs(branchRefTarget); |
| 229 | () => branchesSearchData?.pages.flatMap((page) => page.refs) ?? [], | 216 | const refs = branchRefState.data?.refs ?? EMPTY_REFS; |
| 230 | [branchesSearchData?.pages], | 217 | const hasNextPage = |
| 231 | ); | 218 | branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; |
| 219 | const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); | ||
| 220 | const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; | ||
| 232 | const currentGitBranch = | 221 | const currentGitBranch = |
| 233 | branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; | 222 | branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; |
| 234 | const sourceControlPresentation = useMemo( | 223 | const sourceControlPresentation = useMemo( |
| ... | @@ -293,8 +282,8 @@ export function BranchToolbarBranchSelector({ | ... | @@ -293,8 +282,8 @@ export function BranchToolbarBranchSelector({ |
| 293 | ); | 282 | ); |
| 294 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); | 283 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); |
| 295 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; | 284 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; |
| 296 | const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; | 285 | const totalBranchCount = branchRefState.data?.totalCount ?? 0; |
| 297 | const branchStatusText = isBranchesSearchPending | 286 | const branchStatusText = isInitialBranchesLoadPending |
| 298 | ? "Loading refs..." | 287 | ? "Loading refs..." |
| 299 | : isFetchingNextPage | 288 | : isFetchingNextPage |
| 300 | ? "Loading more refs..." | 289 | ? "Loading more refs..." |
| ... | @@ -308,8 +297,8 @@ export function BranchToolbarBranchSelector({ | ... | @@ -308,8 +297,8 @@ export function BranchToolbarBranchSelector({ |
| 308 | const runBranchAction = (action: () => Promise<void>) => { | 297 | const runBranchAction = (action: () => Promise<void>) => { |
| 309 | startBranchActionTransition(async () => { | 298 | startBranchActionTransition(async () => { |
| 310 | await action().catch(() => undefined); | 299 | await action().catch(() => undefined); |
| 311 | await queryClient | 300 | await vcsRefManager |
| 312 | .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) }) | 301 | .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) |
| 313 | .catch(() => undefined); | 302 | .catch(() => undefined); |
| 314 | }); | 303 | }); |
| 315 | }; | 304 | }; |
| ... | @@ -425,14 +414,25 @@ export function BranchToolbarBranchSelector({ | ... | @@ -425,14 +414,25 @@ export function BranchToolbarBranchSelector({ |
| 425 | setBranchQuery(""); | 414 | setBranchQuery(""); |
| 426 | return; | 415 | return; |
| 427 | } | 416 | } |
| 428 | void queryClient.invalidateQueries({ | 417 | void vcsRefManager |
| 429 | queryKey: gitQueryKeys.refs(environmentId, branchCwd), | 418 | .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) |
| 430 | }); | 419 | .catch(() => undefined); |
| 431 | }, | 420 | }, |
| 432 | [branchCwd, environmentId, queryClient], | 421 | [branchRefTarget], |
| 433 | ); | 422 | ); |
| 434 | 423 | ||
| 435 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); | 424 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); |
| 425 | const fetchNextBranchPage = useCallback(() => { | ||
| 426 | if (!hasNextPage || isFetchingNextPage) { | ||
| 427 | return; | ||
| 428 | } | ||
| 429 | |||
| 430 | setIsFetchingNextPage(true); | ||
| 431 | void vcsRefManager | ||
| 432 | .loadNext(branchRefTarget, undefined, { limit: 100 }) | ||
| 433 | .catch(() => undefined) | ||
| 434 | .finally(() => setIsFetchingNextPage(false)); | ||
| 435 | }, [branchRefTarget, hasNextPage, isFetchingNextPage]); | ||
| 436 | const maybeFetchNextBranchPage = useCallback(() => { | 436 | const maybeFetchNextBranchPage = useCallback(() => { |
| 437 | if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { | 437 | if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { |
| 438 | return; | 438 | return; |
| ... | @@ -449,8 +449,8 @@ export function BranchToolbarBranchSelector({ | ... | @@ -449,8 +449,8 @@ export function BranchToolbarBranchSelector({ |
| 449 | return; | 449 | return; |
| 450 | } | 450 | } |
| 451 | 451 | ||
| 452 | void fetchNextPage().catch(() => undefined); | 452 | fetchNextBranchPage(); |
| 453 | }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); | 453 | }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); |
| 454 | const branchListRef = useRef<LegendListRef | null>(null); | 454 | const branchListRef = useRef<LegendListRef | null>(null); |
| 455 | const setBranchListRef = useCallback((element: HTMLDivElement | null) => { | 455 | const setBranchListRef = useCallback((element: HTMLDivElement | null) => { |
| 456 | branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; | 456 | branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; |
| ... | @@ -592,7 +592,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -592,7 +592,7 @@ export function BranchToolbarBranchSelector({ |
| 592 | <ComboboxTrigger | 592 | <ComboboxTrigger |
| 593 | render={<Button variant="ghost" size="xs" />} | 593 | render={<Button variant="ghost" size="xs" />} |
| 594 | className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} | 594 | className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} |
| 595 | disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} | 595 | disabled={isInitialBranchesLoadPending || isBranchActionPending} |
| 596 | > | 596 | > |
| 597 | <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span> | 597 | <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span> |
| 598 | <ChevronDownIcon className="shrink-0" /> | 598 | <ChevronDownIcon className="shrink-0" /> |
| ... | @@ -622,7 +622,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -622,7 +622,7 @@ export function BranchToolbarBranchSelector({ |
| 622 | drawDistance={336} | 622 | drawDistance={336} |
| 623 | onEndReached={() => { | 623 | onEndReached={() => { |
| 624 | if (hasNextPage && !isFetchingNextPage) { | 624 | if (hasNextPage && !isFetchingNextPage) { |
| 625 | void fetchNextPage().catch(() => undefined); | 625 | fetchNextBranchPage(); |
| 626 | } | 626 | } |
| 627 | }} | 627 | }} |
| 628 | style={{ maxHeight: "14rem" }} | 628 | style={{ maxHeight: "14rem" }} |
apps/web/src/components/ChatView.browser.tsx+176-32| ... | @@ -12,12 +12,14 @@ import { | ... | @@ -12,12 +12,14 @@ import { |
| 12 | ProviderDriverKind, | 12 | ProviderDriverKind, |
| 13 | ProviderInstanceId, | 13 | ProviderInstanceId, |
| 14 | type ServerConfig, | 14 | type ServerConfig, |
| 15 | type TerminalMetadataStreamEvent, | ||
| 15 | type ServerLifecycleWelcomePayload, | 16 | type ServerLifecycleWelcomePayload, |
| 16 | type ThreadId, | 17 | type ThreadId, |
| 17 | type TurnId, | 18 | type TurnId, |
| 18 | WS_METHODS, | 19 | WS_METHODS, |
| 19 | OrchestrationSessionStatus, | 20 | OrchestrationSessionStatus, |
| 20 | DEFAULT_SERVER_SETTINGS, | 21 | DEFAULT_SERVER_SETTINGS, |
| 22 | DEFAULT_TERMINAL_ID, | ||
| 21 | ServerConfig as ServerConfigSchema, | 23 | ServerConfig as ServerConfigSchema, |
| 22 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 23 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | 25 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; |
| ... | @@ -55,19 +57,46 @@ import { getServerConfig } from "../rpc/serverState"; | ... | @@ -55,19 +57,46 @@ import { getServerConfig } from "../rpc/serverState"; |
| 55 | import { getRouter } from "../router"; | 57 | import { getRouter } from "../router"; |
| 56 | import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; | 58 | import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; |
| 57 | import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; | 59 | import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; |
| 58 | import { useTerminalStateStore } from "../terminalStateStore"; | 60 | import { terminalSessionManager } from "../terminalSessionState"; |
| 61 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; | ||
| 59 | import { useUiStateStore } from "../uiStateStore"; | 62 | import { useUiStateStore } from "../uiStateStore"; |
| 60 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; | 63 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; |
| 61 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; | 64 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; |
| 62 | 65 | ||
| 63 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | 66 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| 64 | 67 | ||
| 65 | vi.mock("../lib/gitStatusState", () => ({ | 68 | vi.mock("../lib/vcsStatusState", () => { |
| 66 | useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), | 69 | const status = { |
| 67 | useGitStatuses: () => new Map(), | 70 | data: { |
| 68 | refreshGitStatus: () => Promise.resolve(null), | 71 | isRepo: true, |
| 69 | resetGitStatusStateForTests: () => undefined, | 72 | sourceControlProvider: { |
| 70 | })); | 73 | kind: "github", |
| 74 | name: "GitHub", | ||
| 75 | baseUrl: "https://github.com", | ||
| 76 | }, | ||
| 77 | hasPrimaryRemote: true, | ||
| 78 | isDefaultRef: true, | ||
| 79 | refName: "main", | ||
| 80 | hasWorkingTreeChanges: false, | ||
| 81 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 82 | hasUpstream: true, | ||
| 83 | aheadCount: 0, | ||
| 84 | behindCount: 0, | ||
| 85 | pr: null, | ||
| 86 | }, | ||
| 87 | error: null, | ||
| 88 | cause: null, | ||
| 89 | isPending: false, | ||
| 90 | }; | ||
| 91 | |||
| 92 | return { | ||
| 93 | getVcsStatusSnapshot: () => status, | ||
| 94 | useVcsStatus: () => status, | ||
| 95 | useVcsStatuses: () => new Map(), | ||
| 96 | refreshVcsStatus: () => Promise.resolve(null), | ||
| 97 | resetVcsStatusStateForTests: () => undefined, | ||
| 98 | }; | ||
| 99 | }); | ||
| 71 | 100 | ||
| 72 | const THREAD_ID = "thread-browser-test" as ThreadId; | 101 | const THREAD_ID = "thread-browser-test" as ThreadId; |
| 73 | const THREAD_TITLE = "Browser test thread"; | 102 | const THREAD_TITLE = "Browser test thread"; |
| ... | @@ -101,6 +130,7 @@ interface TestFixture { | ... | @@ -101,6 +130,7 @@ interface TestFixture { |
| 101 | snapshot: OrchestrationReadModel; | 130 | snapshot: OrchestrationReadModel; |
| 102 | serverConfig: ServerConfig; | 131 | serverConfig: ServerConfig; |
| 103 | welcome: ServerLifecycleWelcomePayload; | 132 | welcome: ServerLifecycleWelcomePayload; |
| 133 | terminalMetadataEvents: ReadonlyArray<TerminalMetadataStreamEvent>; | ||
| 104 | } | 134 | } |
| 105 | 135 | ||
| 106 | let fixture: TestFixture; | 136 | let fixture: TestFixture; |
| ... | @@ -213,6 +243,7 @@ function createMockEnvironmentApi(input: { | ... | @@ -213,6 +243,7 @@ function createMockEnvironmentApi(input: { |
| 213 | sourceControl: {} as EnvironmentApi["sourceControl"], | 243 | sourceControl: {} as EnvironmentApi["sourceControl"], |
| 214 | vcs: {} as EnvironmentApi["vcs"], | 244 | vcs: {} as EnvironmentApi["vcs"], |
| 215 | git: {} as EnvironmentApi["git"], | 245 | git: {} as EnvironmentApi["git"], |
| 246 | review: {} as EnvironmentApi["review"], | ||
| 216 | orchestration: { | 247 | orchestration: { |
| 217 | dispatchCommand: input.dispatchCommand, | 248 | dispatchCommand: input.dispatchCommand, |
| 218 | getTurnDiff: (() => { | 249 | getTurnDiff: (() => { |
| ... | @@ -398,6 +429,7 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { | ... | @@ -398,6 +429,7 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { |
| 398 | bootstrapProjectId: PROJECT_ID, | 429 | bootstrapProjectId: PROJECT_ID, |
| 399 | bootstrapThreadId: THREAD_ID, | 430 | bootstrapThreadId: THREAD_ID, |
| 400 | }, | 431 | }, |
| 432 | terminalMetadataEvents: [], | ||
| 401 | }; | 433 | }; |
| 402 | } | 434 | } |
| 403 | 435 | ||
| ... | @@ -1068,6 +1100,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { | ... | @@ -1068,6 +1100,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 1068 | history: "", | 1100 | history: "", |
| 1069 | exitCode: null, | 1101 | exitCode: null, |
| 1070 | exitSignal: null, | 1102 | exitSignal: null, |
| 1103 | label: "Terminal 1", | ||
| 1071 | updatedAt: NOW_ISO, | 1104 | updatedAt: NOW_ISO, |
| 1072 | }; | 1105 | }; |
| 1073 | } | 1106 | } |
| ... | @@ -1691,6 +1724,9 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1691,6 +1724,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1691 | ] | 1724 | ] |
| 1692 | : []; | 1725 | : []; |
| 1693 | } | 1726 | } |
| 1727 | if (request._tag === WS_METHODS.subscribeTerminalMetadata) { | ||
| 1728 | return fixture.terminalMetadataEvents; | ||
| 1729 | } | ||
| 1694 | return []; | 1730 | return []; |
| 1695 | }, | 1731 | }, |
| 1696 | }); | 1732 | }); |
| ... | @@ -1724,12 +1760,9 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1724,12 +1760,9 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1724 | projectOrder: [], | 1760 | projectOrder: [], |
| 1725 | threadLastVisitedAtById: {}, | 1761 | threadLastVisitedAtById: {}, |
| 1726 | }); | 1762 | }); |
| 1727 | useTerminalStateStore.persist.clearStorage(); | 1763 | useTerminalUiStateStore.persist.clearStorage(); |
| 1728 | useTerminalStateStore.setState({ | 1764 | useTerminalUiStateStore.setState({ |
| 1729 | terminalStateByThreadKey: {}, | 1765 | terminalUiStateByThreadKey: {}, |
| 1730 | terminalLaunchContextByThreadKey: {}, | ||
| 1731 | terminalEventEntriesByKey: {}, | ||
| 1732 | nextTerminalEventId: 1, | ||
| 1733 | }); | 1766 | }); |
| 1734 | }); | 1767 | }); |
| 1735 | 1768 | ||
| ... | @@ -1906,37 +1939,50 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1906,37 +1939,50 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1906 | }); | 1939 | }); |
| 1907 | } | 1940 | } |
| 1908 | 1941 | ||
| 1909 | useTerminalStateStore.setState({ | 1942 | useTerminalUiStateStore.setState({ |
| 1910 | terminalStateByThreadKey: { | 1943 | terminalUiStateByThreadKey: { |
| 1911 | [THREAD_KEY]: { | 1944 | [THREAD_KEY]: { |
| 1912 | terminalOpen: true, | 1945 | terminalOpen: true, |
| 1913 | terminalHeight: 280, | 1946 | terminalHeight: 280, |
| 1914 | terminalIds: ["default"], | 1947 | terminalIds: ["default"], |
| 1915 | runningTerminalIds: [], | ||
| 1916 | activeTerminalId: "default", | 1948 | activeTerminalId: "default", |
| 1917 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | 1949 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], |
| 1918 | activeTerminalGroupId: "group-default", | 1950 | activeTerminalGroupId: "group-default", |
| 1919 | }, | 1951 | }, |
| 1920 | }, | 1952 | }, |
| 1921 | terminalLaunchContextByThreadKey: { | ||
| 1922 | [THREAD_KEY]: { | ||
| 1923 | cwd: "/repo/project", | ||
| 1924 | worktreePath: null, | ||
| 1925 | }, | ||
| 1926 | }, | ||
| 1927 | }); | 1953 | }); |
| 1928 | 1954 | ||
| 1929 | const mounted = await mountChatView({ | 1955 | const mounted = await mountChatView({ |
| 1930 | viewport: DEFAULT_VIEWPORT, | 1956 | viewport: DEFAULT_VIEWPORT, |
| 1931 | snapshot, | 1957 | snapshot, |
| 1958 | configureFixture: (nextFixture) => { | ||
| 1959 | nextFixture.terminalMetadataEvents = [ | ||
| 1960 | { | ||
| 1961 | type: "upsert", | ||
| 1962 | terminal: { | ||
| 1963 | threadId: THREAD_ID, | ||
| 1964 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 1965 | cwd: "/repo/project", | ||
| 1966 | worktreePath: null, | ||
| 1967 | status: "running", | ||
| 1968 | pid: 123, | ||
| 1969 | exitCode: null, | ||
| 1970 | exitSignal: null, | ||
| 1971 | hasRunningSubprocess: false, | ||
| 1972 | label: "Terminal 1", | ||
| 1973 | updatedAt: isoAt(0), | ||
| 1974 | }, | ||
| 1975 | }, | ||
| 1976 | ]; | ||
| 1977 | }, | ||
| 1932 | }); | 1978 | }); |
| 1933 | 1979 | ||
| 1934 | try { | 1980 | try { |
| 1935 | await vi.waitFor( | 1981 | await vi.waitFor( |
| 1936 | () => { | 1982 | () => { |
| 1937 | const openRequest = wsRequests.find( | 1983 | const attachRequest = wsRequests |
| 1938 | (request) => request._tag === WS_METHODS.terminalOpen, | 1984 | .toReversed() |
| 1939 | ) as | 1985 | .find((request) => request._tag === WS_METHODS.terminalAttach) as |
| 1940 | | { | 1986 | | { |
| 1941 | _tag: string; | 1987 | _tag: string; |
| 1942 | cwd?: string; | 1988 | cwd?: string; |
| ... | @@ -1944,15 +1990,51 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1944,15 +1990,51 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1944 | env?: Record<string, string>; | 1990 | env?: Record<string, string>; |
| 1945 | } | 1991 | } |
| 1946 | | undefined; | 1992 | | undefined; |
| 1947 | expect(openRequest).toMatchObject({ | 1993 | expect(attachRequest).toMatchObject({ |
| 1948 | _tag: WS_METHODS.terminalOpen, | 1994 | _tag: WS_METHODS.terminalAttach, |
| 1949 | cwd: "/repo/project", | 1995 | cwd: "/repo/project", |
| 1950 | worktreePath: null, | 1996 | worktreePath: null, |
| 1951 | env: { | 1997 | env: { |
| 1952 | T3CODE_PROJECT_ROOT: "/repo/project", | 1998 | T3CODE_PROJECT_ROOT: "/repo/project", |
| 1953 | }, | 1999 | }, |
| 1954 | }); | 2000 | }); |
| 1955 | expect(openRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); | 2001 | expect(attachRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); |
| 2002 | }, | ||
| 2003 | { timeout: 8_000, interval: 16 }, | ||
| 2004 | ); | ||
| 2005 | } finally { | ||
| 2006 | await mounted.cleanup(); | ||
| 2007 | } | ||
| 2008 | }); | ||
| 2009 | |||
| 2010 | it("attaches the default terminal when opening an empty terminal drawer", async () => { | ||
| 2011 | const mounted = await mountChatView({ | ||
| 2012 | viewport: DEFAULT_VIEWPORT, | ||
| 2013 | snapshot: createSnapshotForTargetUser({ | ||
| 2014 | targetMessageId: "msg-user-open-empty-terminal-drawer" as MessageId, | ||
| 2015 | targetText: "open empty terminal drawer", | ||
| 2016 | }), | ||
| 2017 | }); | ||
| 2018 | |||
| 2019 | try { | ||
| 2020 | const toggle = await waitForElement( | ||
| 2021 | () => | ||
| 2022 | document.querySelector<HTMLButtonElement>('button[aria-label="Toggle terminal drawer"]'), | ||
| 2023 | "Unable to find terminal drawer toggle.", | ||
| 2024 | ); | ||
| 2025 | toggle.click(); | ||
| 2026 | |||
| 2027 | await vi.waitFor( | ||
| 2028 | () => { | ||
| 2029 | const attachRequest = wsRequests.find( | ||
| 2030 | (request) => request._tag === WS_METHODS.terminalAttach, | ||
| 2031 | ); | ||
| 2032 | expect(attachRequest).toMatchObject({ | ||
| 2033 | _tag: WS_METHODS.terminalAttach, | ||
| 2034 | threadId: THREAD_ID, | ||
| 2035 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 2036 | cwd: "/repo/project", | ||
| 2037 | }); | ||
| 1956 | }, | 2038 | }, |
| 1957 | { timeout: 8_000, interval: 16 }, | 2039 | { timeout: 8_000, interval: 16 }, |
| 1958 | ); | 2040 | ); |
| ... | @@ -2483,8 +2565,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2483,8 +2565,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2483 | }); | 2565 | }); |
| 2484 | 2566 | ||
| 2485 | it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { | 2567 | it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { |
| 2486 | useTerminalStateStore.setState({ | 2568 | useTerminalUiStateStore.setState({ |
| 2487 | terminalStateByThreadKey: {}, | 2569 | terminalUiStateByThreadKey: {}, |
| 2488 | }); | 2570 | }); |
| 2489 | useComposerDraftStore.setState({ | 2571 | useComposerDraftStore.setState({ |
| 2490 | draftThreadsByThreadKey: { | 2572 | draftThreadsByThreadKey: { |
| ... | @@ -2987,8 +3069,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2987,8 +3069,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2987 | }); | 3069 | }); |
| 2988 | 3070 | ||
| 2989 | it("shows the send state once bootstrap dispatch is in flight", async () => { | 3071 | it("shows the send state once bootstrap dispatch is in flight", async () => { |
| 2990 | useTerminalStateStore.setState({ | 3072 | useTerminalUiStateStore.setState({ |
| 2991 | terminalStateByThreadKey: {}, | 3073 | terminalUiStateByThreadKey: {}, |
| 2992 | }); | 3074 | }); |
| 2993 | useComposerDraftStore.setState({ | 3075 | useComposerDraftStore.setState({ |
| 2994 | draftThreadsByThreadKey: { | 3076 | draftThreadsByThreadKey: { |
| ... | @@ -3811,6 +3893,68 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3811,6 +3893,68 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3811 | } | 3893 | } |
| 3812 | }); | 3894 | }); |
| 3813 | 3895 | ||
| 3896 | it("shows the sidebar terminal indicator from terminal metadata activity", async () => { | ||
| 3897 | const mounted = await mountChatView({ | ||
| 3898 | viewport: DEFAULT_VIEWPORT, | ||
| 3899 | snapshot: createSnapshotForTargetUser({ | ||
| 3900 | targetMessageId: "msg-user-terminal-metadata-indicator" as MessageId, | ||
| 3901 | targetText: "terminal metadata indicator target", | ||
| 3902 | }), | ||
| 3903 | configureFixture: (nextFixture) => { | ||
| 3904 | nextFixture.terminalMetadataEvents = [ | ||
| 3905 | { | ||
| 3906 | type: "upsert", | ||
| 3907 | terminal: { | ||
| 3908 | threadId: THREAD_ID, | ||
| 3909 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 3910 | cwd: "/repo/project", | ||
| 3911 | worktreePath: null, | ||
| 3912 | status: "running", | ||
| 3913 | pid: 123, | ||
| 3914 | exitCode: null, | ||
| 3915 | exitSignal: null, | ||
| 3916 | hasRunningSubprocess: true, | ||
| 3917 | label: "Terminal 1", | ||
| 3918 | updatedAt: isoAt(1_200), | ||
| 3919 | }, | ||
| 3920 | }, | ||
| 3921 | ]; | ||
| 3922 | }, | ||
| 3923 | }); | ||
| 3924 | |||
| 3925 | try { | ||
| 3926 | await vi.waitFor( | ||
| 3927 | () => { | ||
| 3928 | expect( | ||
| 3929 | terminalSessionManager.listSessions({ | ||
| 3930 | environmentId: LOCAL_ENVIRONMENT_ID, | ||
| 3931 | threadId: THREAD_ID, | ||
| 3932 | }), | ||
| 3933 | ).toMatchObject([ | ||
| 3934 | { | ||
| 3935 | state: { | ||
| 3936 | hasRunningSubprocess: true, | ||
| 3937 | }, | ||
| 3938 | }, | ||
| 3939 | ]); | ||
| 3940 | }, | ||
| 3941 | { timeout: 8_000, interval: 16 }, | ||
| 3942 | ); | ||
| 3943 | |||
| 3944 | await vi.waitFor( | ||
| 3945 | () => { | ||
| 3946 | const terminalIndicator = document.querySelector<HTMLElement>( | ||
| 3947 | '[aria-label="Terminal process running"]', | ||
| 3948 | ); | ||
| 3949 | expect(terminalIndicator).not.toBeNull(); | ||
| 3950 | }, | ||
| 3951 | { timeout: 8_000, interval: 16 }, | ||
| 3952 | ); | ||
| 3953 | } finally { | ||
| 3954 | await mounted.cleanup(); | ||
| 3955 | } | ||
| 3956 | }); | ||
| 3957 | |||
| 3814 | it("shows the confirm archive action after clicking the archive button", async () => { | 3958 | it("shows the confirm archive action after clicking the archive button", async () => { |
| 3815 | localStorage.setItem( | 3959 | localStorage.setItem( |
| 3816 | "t3code:client-settings:v1", | 3960 | "t3code:client-settings:v1", |
apps/web/src/components/ChatView.tsx+385-162| ... | @@ -34,11 +34,12 @@ import { | ... | @@ -34,11 +34,12 @@ import { |
| 34 | } from "@t3tools/shared/model"; | 34 | } from "@t3tools/shared/model"; |
| 35 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 35 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 36 | import { truncate } from "@t3tools/shared/String"; | 36 | import { truncate } from "@t3tools/shared/String"; |
| 37 | import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | ||
| 37 | import { Debouncer } from "@tanstack/react-pacer"; | 38 | import { Debouncer } from "@tanstack/react-pacer"; |
| 38 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 39 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 39 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 40 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 40 | import { useShallow } from "zustand/react/shallow"; | 41 | import { useShallow } from "zustand/react/shallow"; |
| 41 | import { useGitStatus } from "~/lib/gitStatusState"; | 42 | import { useVcsStatus } from "~/lib/vcsStatusState"; |
| 42 | import { usePrimaryEnvironmentId } from "../environments/primary"; | 43 | import { usePrimaryEnvironmentId } from "../environments/primary"; |
| 43 | import { readEnvironmentApi } from "../environmentApi"; | 44 | import { readEnvironmentApi } from "../environmentApi"; |
| 44 | import { isElectron } from "../env"; | 45 | import { isElectron } from "../env"; |
| ... | @@ -105,7 +106,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" | ... | @@ -105,7 +106,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" |
| 105 | import PlanSidebar from "./PlanSidebar"; | 106 | import PlanSidebar from "./PlanSidebar"; |
| 106 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 107 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 107 | import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; | 108 | import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; |
| 108 | import { cn, randomHex, randomUUID } from "~/lib/utils"; | 109 | import { cn, randomHex } from "~/lib/utils"; |
| 109 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 110 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 110 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | 111 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 111 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; | 112 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| ... | @@ -141,7 +142,8 @@ import { | ... | @@ -141,7 +142,8 @@ import { |
| 141 | type TerminalContextDraft, | 142 | type TerminalContextDraft, |
| 142 | type TerminalContextSelection, | 143 | type TerminalContextSelection, |
| 143 | } from "../lib/terminalContext"; | 144 | } from "../lib/terminalContext"; |
| 144 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 145 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 146 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; | ||
| 145 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; | 147 | import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; |
| 146 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; | 148 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 147 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | 149 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| ... | @@ -428,6 +430,45 @@ function useLocalDispatchState(input: { | ... | @@ -428,6 +430,45 @@ function useLocalDispatchState(input: { |
| 428 | }; | 430 | }; |
| 429 | } | 431 | } |
| 430 | 432 | ||
| 433 | /** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ | ||
| 434 | function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { | ||
| 435 | if (left.length !== right.length) { | ||
| 436 | return false; | ||
| 437 | } | ||
| 438 | if (left.length === 0) { | ||
| 439 | return true; | ||
| 440 | } | ||
| 441 | const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); | ||
| 442 | const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); | ||
| 443 | for (let index = 0; index < sortedLeft.length; index += 1) { | ||
| 444 | if (sortedLeft[index] !== sortedRight[index]) { | ||
| 445 | return false; | ||
| 446 | } | ||
| 447 | } | ||
| 448 | return true; | ||
| 449 | } | ||
| 450 | |||
| 451 | /** | ||
| 452 | * Server knows about fewer sessions than the client, but every server id still exists locally. | ||
| 453 | * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id | ||
| 454 | * and later re-add it as a separate group (no split layout). | ||
| 455 | */ | ||
| 456 | function serverTerminalIdsStrictSubsetOfClient( | ||
| 457 | serverIds: readonly string[], | ||
| 458 | clientIds: readonly string[], | ||
| 459 | ): boolean { | ||
| 460 | if (serverIds.length >= clientIds.length || clientIds.length === 0) { | ||
| 461 | return false; | ||
| 462 | } | ||
| 463 | const clientSet = new Set(clientIds); | ||
| 464 | for (const id of serverIds) { | ||
| 465 | if (!clientSet.has(id)) { | ||
| 466 | return false; | ||
| 467 | } | ||
| 468 | } | ||
| 469 | return true; | ||
| 470 | } | ||
| 471 | |||
| 431 | interface PersistentThreadTerminalDrawerProps { | 472 | interface PersistentThreadTerminalDrawerProps { |
| 432 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; | 473 | threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; |
| 433 | threadId: ThreadId; | 474 | threadId: ThreadId; |
| ... | @@ -461,14 +502,77 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -461,14 +502,77 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 461 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) | 502 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| 462 | : null; | 503 | : null; |
| 463 | const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); | 504 | const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); |
| 464 | const terminalState = useTerminalStateStore((state) => | 505 | const terminalUiState = useTerminalUiStateStore((state) => |
| 465 | selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), | 506 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), |
| 466 | ); | 507 | ); |
| 467 | const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); | 508 | const knownTerminalSessions = useKnownTerminalSessions({ |
| 468 | const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); | 509 | environmentId: threadRef.environmentId, |
| 469 | const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal); | 510 | threadId, |
| 470 | const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); | 511 | }); |
| 471 | const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); | 512 | const terminalLabelsById = useMemo(() => { |
| 513 | const next = new Map<string, string>(); | ||
| 514 | for (const session of knownTerminalSessions) { | ||
| 515 | next.set( | ||
| 516 | session.target.terminalId, | ||
| 517 | resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), | ||
| 518 | ); | ||
| 519 | } | ||
| 520 | return next; | ||
| 521 | }, [knownTerminalSessions]); | ||
| 522 | const terminalLaunchLocationsById = useMemo(() => { | ||
| 523 | const next = new Map< | ||
| 524 | string, | ||
| 525 | { | ||
| 526 | readonly cwd: string; | ||
| 527 | readonly worktreePath: string | null; | ||
| 528 | readonly runtimeEnv: Record<string, string>; | ||
| 529 | } | ||
| 530 | >(); | ||
| 531 | if (!project) { | ||
| 532 | return next; | ||
| 533 | } | ||
| 534 | |||
| 535 | for (const session of knownTerminalSessions) { | ||
| 536 | const summary = session.state.summary; | ||
| 537 | if (!summary) { | ||
| 538 | continue; | ||
| 539 | } | ||
| 540 | const worktreePathForLaunch = | ||
| 541 | launchContext !== null ? launchContext.worktreePath : summary.worktreePath; | ||
| 542 | next.set(session.target.terminalId, { | ||
| 543 | cwd: launchContext?.cwd ?? summary.cwd, | ||
| 544 | worktreePath: worktreePathForLaunch, | ||
| 545 | runtimeEnv: projectScriptRuntimeEnv({ | ||
| 546 | project: { cwd: project.cwd }, | ||
| 547 | worktreePath: worktreePathForLaunch, | ||
| 548 | }), | ||
| 549 | }); | ||
| 550 | } | ||
| 551 | |||
| 552 | return next; | ||
| 553 | }, [knownTerminalSessions, launchContext, project]); | ||
| 554 | const serverOrderedTerminalIds = useMemo( | ||
| 555 | () => knownTerminalSessions.map((session) => session.target.terminalId), | ||
| 556 | [knownTerminalSessions], | ||
| 557 | ); | ||
| 558 | const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); | ||
| 559 | const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); | ||
| 560 | const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); | ||
| 561 | const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); | ||
| 562 | const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); | ||
| 563 | const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); | ||
| 564 | |||
| 565 | useEffect(() => { | ||
| 566 | if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { | ||
| 567 | return; | ||
| 568 | } | ||
| 569 | if ( | ||
| 570 | serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) | ||
| 571 | ) { | ||
| 572 | return; | ||
| 573 | } | ||
| 574 | reconcileTerminalIds(threadRef, serverOrderedTerminalIds); | ||
| 575 | }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); | ||
| 472 | const [localFocusRequestId, setLocalFocusRequestId] = useState(0); | 576 | const [localFocusRequestId, setLocalFocusRequestId] = useState(0); |
| 473 | const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | 577 | const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| 474 | const effectiveWorktreePath = useMemo(() => { | 578 | const effectiveWorktreePath = useMemo(() => { |
| ... | @@ -514,14 +618,68 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -514,14 +618,68 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 514 | ); | 618 | ); |
| 515 | 619 | ||
| 516 | const splitTerminal = useCallback(() => { | 620 | const splitTerminal = useCallback(() => { |
| 517 | storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); | 621 | const api = readEnvironmentApi(threadRef.environmentId); |
| 622 | if (!api || !cwd) { | ||
| 623 | return; | ||
| 624 | } | ||
| 625 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | ||
| 626 | storeSplitTerminal(threadRef, terminalId); | ||
| 518 | bumpFocusRequestId(); | 627 | bumpFocusRequestId(); |
| 519 | }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); | 628 | void (async () => { |
| 629 | try { | ||
| 630 | await api.terminal.open({ | ||
| 631 | threadId, | ||
| 632 | terminalId, | ||
| 633 | cwd, | ||
| 634 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), | ||
| 635 | env: runtimeEnv, | ||
| 636 | }); | ||
| 637 | } catch { | ||
| 638 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 639 | } | ||
| 640 | })(); | ||
| 641 | }, [ | ||
| 642 | bumpFocusRequestId, | ||
| 643 | cwd, | ||
| 644 | effectiveWorktreePath, | ||
| 645 | runtimeEnv, | ||
| 646 | serverOrderedTerminalIds, | ||
| 647 | storeSplitTerminal, | ||
| 648 | threadId, | ||
| 649 | threadRef, | ||
| 650 | ]); | ||
| 520 | 651 | ||
| 521 | const createNewTerminal = useCallback(() => { | 652 | const createNewTerminal = useCallback(() => { |
| 522 | storeNewTerminal(threadRef, `terminal-${randomUUID()}`); | 653 | const api = readEnvironmentApi(threadRef.environmentId); |
| 654 | if (!api || !cwd) { | ||
| 655 | return; | ||
| 656 | } | ||
| 657 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | ||
| 658 | storeNewTerminal(threadRef, terminalId); | ||
| 523 | bumpFocusRequestId(); | 659 | bumpFocusRequestId(); |
| 524 | }, [bumpFocusRequestId, storeNewTerminal, threadRef]); | 660 | void (async () => { |
| 661 | try { | ||
| 662 | await api.terminal.open({ | ||
| 663 | threadId, | ||
| 664 | terminalId, | ||
| 665 | cwd, | ||
| 666 | ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), | ||
| 667 | env: runtimeEnv, | ||
| 668 | }); | ||
| 669 | } catch { | ||
| 670 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 671 | } | ||
| 672 | })(); | ||
| 673 | }, [ | ||
| 674 | bumpFocusRequestId, | ||
| 675 | cwd, | ||
| 676 | effectiveWorktreePath, | ||
| 677 | runtimeEnv, | ||
| 678 | serverOrderedTerminalIds, | ||
| 679 | storeNewTerminal, | ||
| 680 | threadId, | ||
| 681 | threadRef, | ||
| 682 | ]); | ||
| 525 | 683 | ||
| 526 | const activateTerminal = useCallback( | 684 | const activateTerminal = useCallback( |
| 527 | (terminalId: string) => { | 685 | (terminalId: string) => { |
| ... | @@ -535,7 +693,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -535,7 +693,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 535 | (terminalId: string) => { | 693 | (terminalId: string) => { |
| 536 | const api = readEnvironmentApi(threadRef.environmentId); | 694 | const api = readEnvironmentApi(threadRef.environmentId); |
| 537 | if (!api) return; | 695 | if (!api) return; |
| 538 | const isFinalTerminal = terminalState.terminalIds.length <= 1; | 696 | const isFinalTerminal = terminalUiState.terminalIds.length <= 1; |
| 539 | const fallbackExitWrite = () => | 697 | const fallbackExitWrite = () => |
| 540 | api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); | 698 | api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); |
| 541 | 699 | ||
| ... | @@ -557,7 +715,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -557,7 +715,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 557 | storeCloseTerminal(threadRef, terminalId); | 715 | storeCloseTerminal(threadRef, terminalId); |
| 558 | bumpFocusRequestId(); | 716 | bumpFocusRequestId(); |
| 559 | }, | 717 | }, |
| 560 | [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], | 718 | [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef], |
| 561 | ); | 719 | ); |
| 562 | 720 | ||
| 563 | const handleAddTerminalContext = useCallback( | 721 | const handleAddTerminalContext = useCallback( |
| ... | @@ -570,7 +728,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -570,7 +728,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 570 | [onAddTerminalContext, visible], | 728 | [onAddTerminalContext, visible], |
| 571 | ); | 729 | ); |
| 572 | 730 | ||
| 573 | if (!project || !terminalState.terminalOpen || !cwd) { | 731 | if (!project || !terminalUiState.terminalOpen || !cwd) { |
| 574 | return null; | 732 | return null; |
| 575 | } | 733 | } |
| 576 | 734 | ||
| ... | @@ -583,11 +741,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -583,11 +741,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 583 | worktreePath={effectiveWorktreePath} | 741 | worktreePath={effectiveWorktreePath} |
| 584 | runtimeEnv={runtimeEnv} | 742 | runtimeEnv={runtimeEnv} |
| 585 | visible={visible} | 743 | visible={visible} |
| 586 | height={terminalState.terminalHeight} | 744 | height={terminalUiState.terminalHeight} |
| 587 | terminalIds={terminalState.terminalIds} | 745 | // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable. |
| 588 | activeTerminalId={terminalState.activeTerminalId} | 746 | terminalIds={terminalUiState.terminalIds} |
| 589 | terminalGroups={terminalState.terminalGroups} | 747 | activeTerminalId={terminalUiState.activeTerminalId} |
| 590 | activeTerminalGroupId={terminalState.activeTerminalGroupId} | 748 | terminalGroups={terminalUiState.terminalGroups} |
| 749 | activeTerminalGroupId={terminalUiState.activeTerminalGroupId} | ||
| 591 | focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} | 750 | focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} |
| 592 | onSplitTerminal={splitTerminal} | 751 | onSplitTerminal={splitTerminal} |
| 593 | onNewTerminal={createNewTerminal} | 752 | onNewTerminal={createNewTerminal} |
| ... | @@ -599,6 +758,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -599,6 +758,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 599 | onCloseTerminal={closeTerminal} | 758 | onCloseTerminal={closeTerminal} |
| 600 | onHeightChange={setTerminalHeight} | 759 | onHeightChange={setTerminalHeight} |
| 601 | onAddTerminalContext={handleAddTerminalContext} | 760 | onAddTerminalContext={handleAddTerminalContext} |
| 761 | terminalLabelsById={terminalLabelsById} | ||
| 762 | terminalLaunchLocationsById={terminalLaunchLocationsById} | ||
| 602 | /> | 763 | /> |
| 603 | </div> | 764 | </div> |
| 604 | ); | 765 | ); |
| ... | @@ -713,9 +874,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -713,9 +874,8 @@ export default function ChatView(props: ChatViewProps) { |
| 713 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 874 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 714 | const [pullRequestDialogState, setPullRequestDialogState] = | 875 | const [pullRequestDialogState, setPullRequestDialogState] = |
| 715 | useState<PullRequestDialogState | null>(null); | 876 | useState<PullRequestDialogState | null>(null); |
| 716 | const [terminalLaunchContext, setTerminalLaunchContext] = useState<TerminalLaunchContext | null>( | 877 | const [terminalUiLaunchContext, setTerminalUiLaunchContext] = |
| 717 | null, | 878 | useState<TerminalLaunchContext | null>(null); |
| 718 | ); | ||
| 719 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< | 879 | const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< |
| 720 | Record<string, string[]> | 880 | Record<string, string[]> |
| 721 | >({}); | 881 | >({}); |
| ... | @@ -732,23 +892,24 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -732,23 +892,24 @@ export default function ChatView(props: ChatViewProps) { |
| 732 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); | 892 | const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({}); |
| 733 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); | 893 | const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({}); |
| 734 | const sendInFlightRef = useRef(false); | 894 | const sendInFlightRef = useRef(false); |
| 735 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); | 895 | const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 736 | 896 | ||
| 737 | const terminalState = useTerminalStateStore((state) => | 897 | const terminalUiState = useTerminalUiStateStore((state) => |
| 738 | selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), | 898 | selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), |
| 739 | ); | 899 | ); |
| 740 | const openTerminalThreadKeys = useTerminalStateStore( | 900 | const openTerminalThreadKeys = useTerminalUiStateStore( |
| 741 | useShallow((state) => | 901 | useShallow((state) => |
| 742 | Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => | 902 | Object.entries(state.terminalUiStateByThreadKey).flatMap( |
| 743 | nextTerminalState.terminalOpen ? [nextThreadKey] : [], | 903 | ([nextThreadKey, nextTerminalUiState]) => |
| 904 | nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], | ||
| 744 | ), | 905 | ), |
| 745 | ), | 906 | ), |
| 746 | ); | 907 | ); |
| 747 | const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); | 908 | const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); |
| 748 | const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); | 909 | const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); |
| 749 | const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); | 910 | const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); |
| 750 | const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); | 911 | const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); |
| 751 | const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); | 912 | const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); |
| 752 | const serverThreadKeys = useStore( | 913 | const serverThreadKeys = useStore( |
| 753 | useShallow((state) => | 914 | useShallow((state) => |
| 754 | selectThreadsAcrossEnvironments(state).map((thread) => | 915 | selectThreadsAcrossEnvironments(state).map((thread) => |
| ... | @@ -756,12 +917,6 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -756,12 +917,6 @@ export default function ChatView(props: ChatViewProps) { |
| 756 | ), | 917 | ), |
| 757 | ), | 918 | ), |
| 758 | ); | 919 | ); |
| 759 | const storeServerTerminalLaunchContext = useTerminalStateStore( | ||
| 760 | (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, | ||
| 761 | ); | ||
| 762 | const storeClearTerminalLaunchContext = useTerminalStateStore( | ||
| 763 | (s) => s.clearTerminalLaunchContext, | ||
| 764 | ); | ||
| 765 | const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); | 920 | const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); |
| 766 | const draftThreadKeys = useMemo( | 921 | const draftThreadKeys = useMemo( |
| 767 | () => | 922 | () => |
| ... | @@ -814,11 +969,60 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -814,11 +969,60 @@ export default function ChatView(props: ChatViewProps) { |
| 814 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; | 969 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 815 | const diffOpen = rawSearch.diff === "1"; | 970 | const diffOpen = rawSearch.diff === "1"; |
| 816 | const activeThreadId = activeThread?.id ?? null; | 971 | const activeThreadId = activeThread?.id ?? null; |
| 972 | const runningTerminalIds = useThreadRunningTerminalIds({ | ||
| 973 | environmentId: activeThread?.environmentId ?? null, | ||
| 974 | threadId: activeThreadId, | ||
| 975 | }); | ||
| 976 | const activeThreadKnownSessionsRaw = useKnownTerminalSessions({ | ||
| 977 | environmentId: activeThread?.environmentId ?? null, | ||
| 978 | threadId: activeThreadId, | ||
| 979 | }); | ||
| 980 | const activeThreadKnownSessions = useMemo(() => { | ||
| 981 | if (activeThreadId === null) { | ||
| 982 | return []; | ||
| 983 | } | ||
| 984 | return activeThreadKnownSessionsRaw.filter( | ||
| 985 | (session) => session.target.threadId === activeThreadId, | ||
| 986 | ); | ||
| 987 | }, [activeThreadId, activeThreadKnownSessionsRaw]); | ||
| 988 | const activeServerOrderedTerminalIds = useMemo( | ||
| 989 | () => activeThreadKnownSessions.map((session) => session.target.terminalId), | ||
| 990 | [activeThreadKnownSessions], | ||
| 991 | ); | ||
| 992 | const activeKnownTerminalIds = useMemo( | ||
| 993 | () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], | ||
| 994 | [activeServerOrderedTerminalIds, terminalUiState.terminalIds], | ||
| 995 | ); | ||
| 996 | const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); | ||
| 817 | const activeThreadRef = useMemo( | 997 | const activeThreadRef = useMemo( |
| 818 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), | 998 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), |
| 819 | [activeThread], | 999 | [activeThread], |
| 820 | ); | 1000 | ); |
| 821 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; | 1001 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1002 | |||
| 1003 | useEffect(() => { | ||
| 1004 | if (!activeThreadRef) { | ||
| 1005 | return; | ||
| 1006 | } | ||
| 1007 | if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) { | ||
| 1008 | return; | ||
| 1009 | } | ||
| 1010 | if ( | ||
| 1011 | serverTerminalIdsStrictSubsetOfClient( | ||
| 1012 | activeServerOrderedTerminalIds, | ||
| 1013 | terminalUiState.terminalIds, | ||
| 1014 | ) | ||
| 1015 | ) { | ||
| 1016 | return; | ||
| 1017 | } | ||
| 1018 | reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds); | ||
| 1019 | }, [ | ||
| 1020 | activeThreadRef, | ||
| 1021 | activeServerOrderedTerminalIds, | ||
| 1022 | reconcileTerminalIds, | ||
| 1023 | terminalUiState.terminalIds, | ||
| 1024 | ]); | ||
| 1025 | |||
| 822 | const existingOpenTerminalThreadKeys = useMemo(() => { | 1026 | const existingOpenTerminalThreadKeys = useMemo(() => { |
| 823 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | 1027 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 824 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); | 1028 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| ... | @@ -843,7 +1047,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -843,7 +1047,7 @@ export default function ChatView(props: ChatViewProps) { |
| 843 | currentThreadIds, | 1047 | currentThreadIds, |
| 844 | openThreadIds: existingOpenTerminalThreadKeys, | 1048 | openThreadIds: existingOpenTerminalThreadKeys, |
| 845 | activeThreadId: activeThreadKey, | 1049 | activeThreadId: activeThreadKey, |
| 846 | activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), | 1050 | activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), |
| 847 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 1051 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 848 | }); | 1052 | }); |
| 849 | return currentThreadIds.length === nextThreadIds.length && | 1053 | return currentThreadIds.length === nextThreadIds.length && |
| ... | @@ -851,7 +1055,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -851,7 +1055,7 @@ export default function ChatView(props: ChatViewProps) { |
| 851 | ? currentThreadIds | 1055 | ? currentThreadIds |
| 852 | : nextThreadIds; | 1056 | : nextThreadIds; |
| 853 | }); | 1057 | }); |
| 854 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); | 1058 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); |
| 855 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 1059 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 856 | const activeProjectRef = activeThread | 1060 | const activeProjectRef = activeThread |
| 857 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) | 1061 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) |
| ... | @@ -1631,7 +1835,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1631,7 +1835,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1631 | worktreePath: activeThread?.worktreePath ?? null, | 1835 | worktreePath: activeThread?.worktreePath ?? null, |
| 1632 | }) | 1836 | }) |
| 1633 | : null; | 1837 | : null; |
| 1634 | const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); | 1838 | const gitStatusQuery = useVcsStatus({ environmentId, cwd: gitCwd }); |
| 1635 | const keybindings = useServerKeybindings(); | 1839 | const keybindings = useServerKeybindings(); |
| 1636 | const availableEditors = useServerAvailableEditors(); | 1840 | const availableEditors = useServerAvailableEditors(); |
| 1637 | // Prefer an instance-id match so a custom Codex instance (e.g. | 1841 | // Prefer an instance-id match so a custom Codex instance (e.g. |
| ... | @@ -1656,28 +1860,26 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1656,28 +1860,26 @@ export default function ChatView(props: ChatViewProps) { |
| 1656 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; | 1860 | const activeThreadWorktreePath = activeThread?.worktreePath ?? null; |
| 1657 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; | 1861 | const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; |
| 1658 | const activeTerminalLaunchContext = | 1862 | const activeTerminalLaunchContext = |
| 1659 | terminalLaunchContext?.threadId === activeThreadId | 1863 | terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; |
| 1660 | ? terminalLaunchContext | ||
| 1661 | : (storeServerTerminalLaunchContext ?? null); | ||
| 1662 | // Default true while loading to avoid toolbar flicker. | 1864 | // Default true while loading to avoid toolbar flicker. |
| 1663 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; | 1865 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; |
| 1664 | const terminalShortcutLabelOptions = useMemo( | 1866 | const terminalShortcutLabelOptions = useMemo( |
| 1665 | () => ({ | 1867 | () => ({ |
| 1666 | context: { | 1868 | context: { |
| 1667 | terminalFocus: true, | 1869 | terminalFocus: true, |
| 1668 | terminalOpen: Boolean(terminalState.terminalOpen), | 1870 | terminalOpen: Boolean(terminalUiState.terminalOpen), |
| 1669 | }, | 1871 | }, |
| 1670 | }), | 1872 | }), |
| 1671 | [terminalState.terminalOpen], | 1873 | [terminalUiState.terminalOpen], |
| 1672 | ); | 1874 | ); |
| 1673 | const nonTerminalShortcutLabelOptions = useMemo( | 1875 | const nonTerminalShortcutLabelOptions = useMemo( |
| 1674 | () => ({ | 1876 | () => ({ |
| 1675 | context: { | 1877 | context: { |
| 1676 | terminalFocus: false, | 1878 | terminalFocus: false, |
| 1677 | terminalOpen: Boolean(terminalState.terminalOpen), | 1879 | terminalOpen: Boolean(terminalUiState.terminalOpen), |
| 1678 | }, | 1880 | }, |
| 1679 | }), | 1881 | }), |
| 1680 | [terminalState.terminalOpen], | 1882 | [terminalUiState.terminalOpen], |
| 1681 | ); | 1883 | ); |
| 1682 | const terminalToggleShortcutLabel = useMemo( | 1884 | const terminalToggleShortcutLabel = useMemo( |
| 1683 | () => shortcutLabelForCommand(keybindings, "terminal.toggle"), | 1885 | () => shortcutLabelForCommand(keybindings, "terminal.toggle"), |
| ... | @@ -1744,11 +1946,11 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1744,11 +1946,11 @@ export default function ChatView(props: ChatViewProps) { |
| 1744 | ); | 1946 | ); |
| 1745 | 1947 | ||
| 1746 | const activeTerminalGroup = | 1948 | const activeTerminalGroup = |
| 1747 | terminalState.terminalGroups.find( | 1949 | terminalUiState.terminalGroups.find( |
| 1748 | (group) => group.id === terminalState.activeTerminalGroupId, | 1950 | (group) => group.id === terminalUiState.activeTerminalGroupId, |
| 1749 | ) ?? | 1951 | ) ?? |
| 1750 | terminalState.terminalGroups.find((group) => | 1952 | terminalUiState.terminalGroups.find((group) => |
| 1751 | group.terminalIds.includes(terminalState.activeTerminalId), | 1953 | group.terminalIds.includes(terminalUiState.activeTerminalId), |
| 1752 | ) ?? | 1954 | ) ?? |
| 1753 | null; | 1955 | null; |
| 1754 | const hasReachedSplitLimit = | 1956 | const hasReachedSplitLimit = |
| ... | @@ -1800,25 +2002,96 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1800,25 +2002,96 @@ export default function ChatView(props: ChatViewProps) { |
| 1800 | ); | 2002 | ); |
| 1801 | const toggleTerminalVisibility = useCallback(() => { | 2003 | const toggleTerminalVisibility = useCallback(() => { |
| 1802 | if (!activeThreadRef) return; | 2004 | if (!activeThreadRef) return; |
| 1803 | setTerminalOpen(!terminalState.terminalOpen); | 2005 | setTerminalOpen(!terminalUiState.terminalOpen); |
| 1804 | }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); | 2006 | }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); |
| 1805 | const splitTerminal = useCallback(() => { | 2007 | const splitTerminal = useCallback(() => { |
| 1806 | if (!activeThreadRef || hasReachedSplitLimit) return; | 2008 | if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { |
| 1807 | const terminalId = `terminal-${randomUUID()}`; | 2009 | return; |
| 2010 | } | ||
| 2011 | const cwdForOpen = gitCwd ?? activeProject.cwd; | ||
| 2012 | if (!cwdForOpen) { | ||
| 2013 | return; | ||
| 2014 | } | ||
| 2015 | const api = readEnvironmentApi(environmentId); | ||
| 2016 | if (!api) { | ||
| 2017 | return; | ||
| 2018 | } | ||
| 2019 | const terminalId = nextTerminalId(activeKnownTerminalIds); | ||
| 1808 | storeSplitTerminal(activeThreadRef, terminalId); | 2020 | storeSplitTerminal(activeThreadRef, terminalId); |
| 1809 | setTerminalFocusRequestId((value) => value + 1); | 2021 | setTerminalFocusRequestId((value) => value + 1); |
| 1810 | }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); | 2022 | void (async () => { |
| 2023 | try { | ||
| 2024 | await api.terminal.open({ | ||
| 2025 | threadId: activeThreadId, | ||
| 2026 | terminalId, | ||
| 2027 | cwd: cwdForOpen, | ||
| 2028 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | ||
| 2029 | env: projectScriptRuntimeEnv({ | ||
| 2030 | project: { cwd: activeProject.cwd }, | ||
| 2031 | worktreePath: activeThreadWorktreePath, | ||
| 2032 | }), | ||
| 2033 | }); | ||
| 2034 | } catch { | ||
| 2035 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 2036 | } | ||
| 2037 | })(); | ||
| 2038 | }, [ | ||
| 2039 | activeProject, | ||
| 2040 | activeKnownTerminalIds, | ||
| 2041 | activeThreadId, | ||
| 2042 | activeThreadRef, | ||
| 2043 | activeThreadWorktreePath, | ||
| 2044 | environmentId, | ||
| 2045 | gitCwd, | ||
| 2046 | hasReachedSplitLimit, | ||
| 2047 | storeSplitTerminal, | ||
| 2048 | ]); | ||
| 1811 | const createNewTerminal = useCallback(() => { | 2049 | const createNewTerminal = useCallback(() => { |
| 1812 | if (!activeThreadRef) return; | 2050 | if (!activeThreadRef || !activeThreadId || !activeProject) { |
| 1813 | const terminalId = `terminal-${randomUUID()}`; | 2051 | return; |
| 2052 | } | ||
| 2053 | const cwdForOpen = gitCwd ?? activeProject.cwd; | ||
| 2054 | if (!cwdForOpen) { | ||
| 2055 | return; | ||
| 2056 | } | ||
| 2057 | const api = readEnvironmentApi(environmentId); | ||
| 2058 | if (!api) { | ||
| 2059 | return; | ||
| 2060 | } | ||
| 2061 | const terminalId = nextTerminalId(activeKnownTerminalIds); | ||
| 1814 | storeNewTerminal(activeThreadRef, terminalId); | 2062 | storeNewTerminal(activeThreadRef, terminalId); |
| 1815 | setTerminalFocusRequestId((value) => value + 1); | 2063 | setTerminalFocusRequestId((value) => value + 1); |
| 1816 | }, [activeThreadRef, storeNewTerminal]); | 2064 | void (async () => { |
| 2065 | try { | ||
| 2066 | await api.terminal.open({ | ||
| 2067 | threadId: activeThreadId, | ||
| 2068 | terminalId, | ||
| 2069 | cwd: cwdForOpen, | ||
| 2070 | ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), | ||
| 2071 | env: projectScriptRuntimeEnv({ | ||
| 2072 | project: { cwd: activeProject.cwd }, | ||
| 2073 | worktreePath: activeThreadWorktreePath, | ||
| 2074 | }), | ||
| 2075 | }); | ||
| 2076 | } catch { | ||
| 2077 | // Opening failed; the tab is already in the store — user can retry or close it. | ||
| 2078 | } | ||
| 2079 | })(); | ||
| 2080 | }, [ | ||
| 2081 | activeProject, | ||
| 2082 | activeKnownTerminalIds, | ||
| 2083 | activeThreadId, | ||
| 2084 | activeThreadRef, | ||
| 2085 | activeThreadWorktreePath, | ||
| 2086 | environmentId, | ||
| 2087 | gitCwd, | ||
| 2088 | storeNewTerminal, | ||
| 2089 | ]); | ||
| 1817 | const closeTerminal = useCallback( | 2090 | const closeTerminal = useCallback( |
| 1818 | (terminalId: string) => { | 2091 | (terminalId: string) => { |
| 1819 | const api = readEnvironmentApi(environmentId); | 2092 | const api = readEnvironmentApi(environmentId); |
| 1820 | if (!activeThreadId || !api) return; | 2093 | if (!activeThreadId || !api || !activeThreadRef) return; |
| 1821 | const isFinalTerminal = terminalState.terminalIds.length <= 1; | 2094 | const isFinalTerminal = activeKnownTerminalIds.length <= 1; |
| 1822 | const fallbackExitWrite = () => | 2095 | const fallbackExitWrite = () => |
| 1823 | api.terminal | 2096 | api.terminal |
| 1824 | .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) | 2097 | .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) |
| ... | @@ -1839,18 +2112,10 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1839,18 +2112,10 @@ export default function ChatView(props: ChatViewProps) { |
| 1839 | } else { | 2112 | } else { |
| 1840 | void fallbackExitWrite(); | 2113 | void fallbackExitWrite(); |
| 1841 | } | 2114 | } |
| 1842 | if (activeThreadRef) { | 2115 | storeCloseTerminal(activeThreadRef, terminalId); |
| 1843 | storeCloseTerminal(activeThreadRef, terminalId); | ||
| 1844 | } | ||
| 1845 | setTerminalFocusRequestId((value) => value + 1); | 2116 | setTerminalFocusRequestId((value) => value + 1); |
| 1846 | }, | 2117 | }, |
| 1847 | [ | 2118 | [activeThreadId, activeThreadRef, activeKnownTerminalIds, environmentId, storeCloseTerminal], |
| 1848 | activeThreadId, | ||
| 1849 | activeThreadRef, | ||
| 1850 | environmentId, | ||
| 1851 | storeCloseTerminal, | ||
| 1852 | terminalState.terminalIds.length, | ||
| 1853 | ], | ||
| 1854 | ); | 2119 | ); |
| 1855 | const runProjectScript = useCallback( | 2120 | const runProjectScript = useCallback( |
| 1856 | async ( | 2121 | async ( |
| ... | @@ -1873,18 +2138,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1873,18 +2138,13 @@ export default function ChatView(props: ChatViewProps) { |
| 1873 | } | 2138 | } |
| 1874 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; | 2139 | const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; |
| 1875 | const baseTerminalId = | 2140 | const baseTerminalId = |
| 1876 | terminalState.activeTerminalId || | 2141 | terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; |
| 1877 | terminalState.terminalIds[0] || | 2142 | const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); |
| 1878 | DEFAULT_THREAD_TERMINAL_ID; | ||
| 1879 | const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); | ||
| 1880 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; | 2143 | const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; |
| 1881 | const shouldCreateNewTerminal = wantsNewTerminal; | 2144 | const shouldCreateNewTerminal = wantsNewTerminal; |
| 1882 | const targetTerminalId = shouldCreateNewTerminal | ||
| 1883 | ? `terminal-${randomUUID()}` | ||
| 1884 | : baseTerminalId; | ||
| 1885 | const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; | 2145 | const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; |
| 1886 | 2146 | ||
| 1887 | setTerminalLaunchContext({ | 2147 | setTerminalUiLaunchContext({ |
| 1888 | threadId: activeThreadId, | 2148 | threadId: activeThreadId, |
| 1889 | cwd: targetCwd, | 2149 | cwd: targetCwd, |
| 1890 | worktreePath: targetWorktreePath, | 2150 | worktreePath: targetWorktreePath, |
| ... | @@ -1893,11 +2153,6 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1893,11 +2153,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1893 | if (!activeThreadRef) { | 2153 | if (!activeThreadRef) { |
| 1894 | return; | 2154 | return; |
| 1895 | } | 2155 | } |
| 1896 | if (shouldCreateNewTerminal) { | ||
| 1897 | storeNewTerminal(activeThreadRef, targetTerminalId); | ||
| 1898 | } else { | ||
| 1899 | storeSetActiveTerminal(activeThreadRef, targetTerminalId); | ||
| 1900 | } | ||
| 1901 | setTerminalFocusRequestId((value) => value + 1); | 2156 | setTerminalFocusRequestId((value) => value + 1); |
| 1902 | 2157 | ||
| 1903 | const runtimeEnv = projectScriptRuntimeEnv({ | 2158 | const runtimeEnv = projectScriptRuntimeEnv({ |
| ... | @@ -1907,6 +2162,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1907,6 +2162,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1907 | worktreePath: targetWorktreePath, | 2162 | worktreePath: targetWorktreePath, |
| 1908 | ...(options?.env ? { extraEnv: options.env } : {}), | 2163 | ...(options?.env ? { extraEnv: options.env } : {}), |
| 1909 | }); | 2164 | }); |
| 2165 | const targetTerminalId = shouldCreateNewTerminal | ||
| 2166 | ? nextTerminalId(activeKnownTerminalIds) | ||
| 2167 | : baseTerminalId; | ||
| 1910 | const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal | 2168 | const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal |
| 1911 | ? { | 2169 | ? { |
| 1912 | threadId: activeThreadId, | 2170 | threadId: activeThreadId, |
| ... | @@ -1925,6 +2183,12 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1925,6 +2183,12 @@ export default function ChatView(props: ChatViewProps) { |
| 1925 | env: runtimeEnv, | 2183 | env: runtimeEnv, |
| 1926 | }; | 2184 | }; |
| 1927 | 2185 | ||
| 2186 | if (shouldCreateNewTerminal) { | ||
| 2187 | storeNewTerminal(activeThreadRef, targetTerminalId); | ||
| 2188 | } else { | ||
| 2189 | storeSetActiveTerminal(activeThreadRef, targetTerminalId); | ||
| 2190 | } | ||
| 2191 | |||
| 1928 | try { | 2192 | try { |
| 1929 | await api.terminal.open(openTerminalInput); | 2193 | await api.terminal.open(openTerminalInput); |
| 1930 | await api.terminal.write({ | 2194 | await api.terminal.write({ |
| ... | @@ -1951,9 +2215,9 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1951,9 +2215,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1951 | storeSetActiveTerminal, | 2215 | storeSetActiveTerminal, |
| 1952 | setLastInvokedScriptByProjectId, | 2216 | setLastInvokedScriptByProjectId, |
| 1953 | environmentId, | 2217 | environmentId, |
| 1954 | terminalState.activeTerminalId, | 2218 | activeKnownTerminalIds, |
| 1955 | terminalState.runningTerminalIds, | 2219 | runningTerminalIds, |
| 1956 | terminalState.terminalIds, | 2220 | terminalUiState.activeTerminalId, |
| 1957 | ], | 2221 | ], |
| 1958 | ); | 2222 | ); |
| 1959 | 2223 | ||
| ... | @@ -2264,14 +2528,14 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2264,14 +2528,14 @@ export default function ChatView(props: ChatViewProps) { |
| 2264 | }, [activeThread?.id]); | 2528 | }, [activeThread?.id]); |
| 2265 | 2529 | ||
| 2266 | useEffect(() => { | 2530 | useEffect(() => { |
| 2267 | if (!activeThread?.id || terminalState.terminalOpen) return; | 2531 | if (!activeThread?.id || terminalUiState.terminalOpen) return; |
| 2268 | const frame = window.requestAnimationFrame(() => { | 2532 | const frame = window.requestAnimationFrame(() => { |
| 2269 | focusComposer(); | 2533 | focusComposer(); |
| 2270 | }); | 2534 | }); |
| 2271 | return () => { | 2535 | return () => { |
| 2272 | window.cancelAnimationFrame(frame); | 2536 | window.cancelAnimationFrame(frame); |
| 2273 | }; | 2537 | }; |
| 2274 | }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); | 2538 | }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]); |
| 2275 | 2539 | ||
| 2276 | useEffect(() => { | 2540 | useEffect(() => { |
| 2277 | if (!activeThread?.id) return; | 2541 | if (!activeThread?.id) return; |
| ... | @@ -2356,22 +2620,21 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2356,22 +2620,21 @@ export default function ChatView(props: ChatViewProps) { |
| 2356 | 2620 | ||
| 2357 | useEffect(() => { | 2621 | useEffect(() => { |
| 2358 | if (!activeThreadId) { | 2622 | if (!activeThreadId) { |
| 2359 | setTerminalLaunchContext(null); | 2623 | setTerminalUiLaunchContext(null); |
| 2360 | storeClearTerminalLaunchContext(routeThreadRef); | ||
| 2361 | return; | 2624 | return; |
| 2362 | } | 2625 | } |
| 2363 | setTerminalLaunchContext((current) => { | 2626 | setTerminalUiLaunchContext((current) => { |
| 2364 | if (!current) return current; | 2627 | if (!current) return current; |
| 2365 | if (current.threadId === activeThreadId) return current; | 2628 | if (current.threadId === activeThreadId) return current; |
| 2366 | return null; | 2629 | return null; |
| 2367 | }); | 2630 | }); |
| 2368 | }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); | 2631 | }, [activeThreadId]); |
| 2369 | 2632 | ||
| 2370 | useEffect(() => { | 2633 | useEffect(() => { |
| 2371 | if (!activeThreadId || !activeProjectCwd) { | 2634 | if (!activeThreadId || !activeProjectCwd) { |
| 2372 | return; | 2635 | return; |
| 2373 | } | 2636 | } |
| 2374 | setTerminalLaunchContext((current) => { | 2637 | setTerminalUiLaunchContext((current) => { |
| 2375 | if (!current || current.threadId !== activeThreadId) { | 2638 | if (!current || current.threadId !== activeThreadId) { |
| 2376 | return current; | 2639 | return current; |
| 2377 | } | 2640 | } |
| ... | @@ -2383,72 +2646,32 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2383,72 +2646,32 @@ export default function ChatView(props: ChatViewProps) { |
| 2383 | settledCwd === current.cwd && | 2646 | settledCwd === current.cwd && |
| 2384 | (activeThreadWorktreePath ?? null) === current.worktreePath | 2647 | (activeThreadWorktreePath ?? null) === current.worktreePath |
| 2385 | ) { | 2648 | ) { |
| 2386 | if (activeThreadRef) { | ||
| 2387 | storeClearTerminalLaunchContext(activeThreadRef); | ||
| 2388 | } | ||
| 2389 | return null; | 2649 | return null; |
| 2390 | } | 2650 | } |
| 2391 | return current; | 2651 | return current; |
| 2392 | }); | 2652 | }); |
| 2393 | }, [ | 2653 | }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath]); |
| 2394 | activeProjectCwd, | ||
| 2395 | activeThreadId, | ||
| 2396 | activeThreadRef, | ||
| 2397 | activeThreadWorktreePath, | ||
| 2398 | storeClearTerminalLaunchContext, | ||
| 2399 | ]); | ||
| 2400 | 2654 | ||
| 2401 | useEffect(() => { | 2655 | useEffect(() => { |
| 2402 | if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { | 2656 | if (terminalUiState.terminalOpen) { |
| 2403 | return; | 2657 | return; |
| 2404 | } | 2658 | } |
| 2405 | const settledCwd = projectScriptCwd({ | 2659 | setTerminalUiLaunchContext((current) => |
| 2406 | project: { cwd: activeProjectCwd }, | 2660 | current?.threadId === activeThreadId ? null : current, |
| 2407 | worktreePath: activeThreadWorktreePath, | 2661 | ); |
| 2408 | }); | 2662 | }, [activeThreadId, terminalUiState.terminalOpen]); |
| 2409 | if ( | ||
| 2410 | settledCwd === storeServerTerminalLaunchContext.cwd && | ||
| 2411 | (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath | ||
| 2412 | ) { | ||
| 2413 | if (activeThreadRef) { | ||
| 2414 | storeClearTerminalLaunchContext(activeThreadRef); | ||
| 2415 | } | ||
| 2416 | } | ||
| 2417 | }, [ | ||
| 2418 | activeProjectCwd, | ||
| 2419 | activeThreadId, | ||
| 2420 | activeThreadRef, | ||
| 2421 | activeThreadWorktreePath, | ||
| 2422 | storeClearTerminalLaunchContext, | ||
| 2423 | storeServerTerminalLaunchContext, | ||
| 2424 | ]); | ||
| 2425 | |||
| 2426 | useEffect(() => { | ||
| 2427 | if (terminalState.terminalOpen) { | ||
| 2428 | return; | ||
| 2429 | } | ||
| 2430 | if (activeThreadRef) { | ||
| 2431 | storeClearTerminalLaunchContext(activeThreadRef); | ||
| 2432 | } | ||
| 2433 | setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); | ||
| 2434 | }, [ | ||
| 2435 | activeThreadId, | ||
| 2436 | activeThreadRef, | ||
| 2437 | storeClearTerminalLaunchContext, | ||
| 2438 | terminalState.terminalOpen, | ||
| 2439 | ]); | ||
| 2440 | 2663 | ||
| 2441 | useEffect(() => { | 2664 | useEffect(() => { |
| 2442 | if (!activeThreadKey) return; | 2665 | if (!activeThreadKey) return; |
| 2443 | const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; | 2666 | const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false; |
| 2444 | const current = Boolean(terminalState.terminalOpen); | 2667 | const current = Boolean(terminalUiState.terminalOpen); |
| 2445 | 2668 | ||
| 2446 | if (!previous && current) { | 2669 | if (!previous && current) { |
| 2447 | terminalOpenByThreadRef.current[activeThreadKey] = current; | 2670 | terminalUiOpenByThreadRef.current[activeThreadKey] = current; |
| 2448 | setTerminalFocusRequestId((value) => value + 1); | 2671 | setTerminalFocusRequestId((value) => value + 1); |
| 2449 | return; | 2672 | return; |
| 2450 | } else if (previous && !current) { | 2673 | } else if (previous && !current) { |
| 2451 | terminalOpenByThreadRef.current[activeThreadKey] = current; | 2674 | terminalUiOpenByThreadRef.current[activeThreadKey] = current; |
| 2452 | const frame = window.requestAnimationFrame(() => { | 2675 | const frame = window.requestAnimationFrame(() => { |
| 2453 | focusComposer(); | 2676 | focusComposer(); |
| 2454 | }); | 2677 | }); |
| ... | @@ -2457,8 +2680,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2457,8 +2680,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2457 | }; | 2680 | }; |
| 2458 | } | 2681 | } |
| 2459 | 2682 | ||
| 2460 | terminalOpenByThreadRef.current[activeThreadKey] = current; | 2683 | terminalUiOpenByThreadRef.current[activeThreadKey] = current; |
| 2461 | }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); | 2684 | }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); |
| 2462 | 2685 | ||
| 2463 | useEffect(() => { | 2686 | useEffect(() => { |
| 2464 | const handler = (event: globalThis.KeyboardEvent) => { | 2687 | const handler = (event: globalThis.KeyboardEvent) => { |
| ... | @@ -2467,7 +2690,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2467,7 +2690,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2467 | } | 2690 | } |
| 2468 | const shortcutContext = { | 2691 | const shortcutContext = { |
| 2469 | terminalFocus: isTerminalFocused(), | 2692 | terminalFocus: isTerminalFocused(), |
| 2470 | terminalOpen: Boolean(terminalState.terminalOpen), | 2693 | terminalOpen: Boolean(terminalUiState.terminalOpen), |
| 2471 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, | 2694 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, |
| 2472 | }; | 2695 | }; |
| 2473 | 2696 | ||
| ... | @@ -2486,7 +2709,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2486,7 +2709,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2486 | if (command === "terminal.split") { | 2709 | if (command === "terminal.split") { |
| 2487 | event.preventDefault(); | 2710 | event.preventDefault(); |
| 2488 | event.stopPropagation(); | 2711 | event.stopPropagation(); |
| 2489 | if (!terminalState.terminalOpen) { | 2712 | if (!terminalUiState.terminalOpen) { |
| 2490 | setTerminalOpen(true); | 2713 | setTerminalOpen(true); |
| 2491 | } | 2714 | } |
| 2492 | splitTerminal(); | 2715 | splitTerminal(); |
| ... | @@ -2496,15 +2719,15 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2496,15 +2719,15 @@ export default function ChatView(props: ChatViewProps) { |
| 2496 | if (command === "terminal.close") { | 2719 | if (command === "terminal.close") { |
| 2497 | event.preventDefault(); | 2720 | event.preventDefault(); |
| 2498 | event.stopPropagation(); | 2721 | event.stopPropagation(); |
| 2499 | if (!terminalState.terminalOpen) return; | 2722 | if (!terminalUiState.terminalOpen) return; |
| 2500 | closeTerminal(terminalState.activeTerminalId); | 2723 | closeTerminal(terminalUiState.activeTerminalId); |
| 2501 | return; | 2724 | return; |
| 2502 | } | 2725 | } |
| 2503 | 2726 | ||
| 2504 | if (command === "terminal.new") { | 2727 | if (command === "terminal.new") { |
| 2505 | event.preventDefault(); | 2728 | event.preventDefault(); |
| 2506 | event.stopPropagation(); | 2729 | event.stopPropagation(); |
| 2507 | if (!terminalState.terminalOpen) { | 2730 | if (!terminalUiState.terminalOpen) { |
| 2508 | setTerminalOpen(true); | 2731 | setTerminalOpen(true); |
| 2509 | } | 2732 | } |
| 2510 | createNewTerminal(); | 2733 | createNewTerminal(); |
| ... | @@ -2537,8 +2760,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2537,8 +2760,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2537 | return () => window.removeEventListener("keydown", handler, true); | 2760 | return () => window.removeEventListener("keydown", handler, true); |
| 2538 | }, [ | 2761 | }, [ |
| 2539 | activeProject, | 2762 | activeProject, |
| 2540 | terminalState.terminalOpen, | 2763 | terminalUiState.terminalOpen, |
| 2541 | terminalState.activeTerminalId, | 2764 | terminalUiState.activeTerminalId, |
| 2542 | activeThreadId, | 2765 | activeThreadId, |
| 2543 | closeTerminal, | 2766 | closeTerminal, |
| 2544 | createNewTerminal, | 2767 | createNewTerminal, |
| ... | @@ -3527,7 +3750,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3527,7 +3750,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3527 | keybindings={keybindings} | 3750 | keybindings={keybindings} |
| 3528 | availableEditors={availableEditors} | 3751 | availableEditors={availableEditors} |
| 3529 | terminalAvailable={activeProject !== undefined} | 3752 | terminalAvailable={activeProject !== undefined} |
| 3530 | terminalOpen={terminalState.terminalOpen} | 3753 | terminalOpen={terminalUiState.terminalOpen} |
| 3531 | terminalToggleShortcutLabel={terminalToggleShortcutLabel} | 3754 | terminalToggleShortcutLabel={terminalToggleShortcutLabel} |
| 3532 | diffToggleShortcutLabel={diffPanelShortcutLabel} | 3755 | diffToggleShortcutLabel={diffPanelShortcutLabel} |
| 3533 | gitCwd={gitCwd} | 3756 | gitCwd={gitCwd} |
| ... | @@ -3649,7 +3872,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3649,7 +3872,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3649 | resolvedTheme={resolvedTheme} | 3872 | resolvedTheme={resolvedTheme} |
| 3650 | settings={settings} | 3873 | settings={settings} |
| 3651 | keybindings={keybindings} | 3874 | keybindings={keybindings} |
| 3652 | terminalOpen={Boolean(terminalState.terminalOpen)} | 3875 | terminalOpen={Boolean(terminalUiState.terminalOpen)} |
| 3653 | gitCwd={gitCwd} | 3876 | gitCwd={gitCwd} |
| 3654 | promptRef={promptRef} | 3877 | promptRef={promptRef} |
| 3655 | composerImagesRef={composerImagesRef} | 3878 | composerImagesRef={composerImagesRef} |
| ... | @@ -3745,7 +3968,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3745,7 +3968,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3745 | key={mountedThreadKey} | 3968 | key={mountedThreadKey} |
| 3746 | threadRef={mountedThreadRef} | 3969 | threadRef={mountedThreadRef} |
| 3747 | threadId={mountedThreadRef.threadId} | 3970 | threadId={mountedThreadRef.threadId} |
| 3748 | visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen} | 3971 | visible={mountedThreadKey === activeThreadKey && terminalUiState.terminalOpen} |
| 3749 | launchContext={ | 3972 | launchContext={ |
| 3750 | mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null | 3973 | mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null |
| 3751 | } | 3974 | } |
apps/web/src/components/CommandPalette.tsx+3-3| ... | @@ -79,7 +79,7 @@ import { | ... | @@ -79,7 +79,7 @@ import { |
| 79 | selectSidebarThreadsAcrossEnvironments, | 79 | selectSidebarThreadsAcrossEnvironments, |
| 80 | useStore, | 80 | useStore, |
| 81 | } from "../store"; | 81 | } from "../store"; |
| 82 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 82 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 83 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; | 83 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; |
| 84 | import { | 84 | import { |
| 85 | ADDON_ICON_CLASS, | 85 | ADDON_ICON_CLASS, |
| ... | @@ -337,9 +337,9 @@ export function CommandPalette({ children }: { children: ReactNode }) { | ... | @@ -337,9 +337,9 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 337 | select: (params) => resolveThreadRouteTarget(params), | 337 | select: (params) => resolveThreadRouteTarget(params), |
| 338 | }); | 338 | }); |
| 339 | const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; | 339 | const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; |
| 340 | const terminalOpen = useTerminalStateStore((state) => | 340 | const terminalOpen = useTerminalUiStateStore((state) => |
| 341 | routeThreadRef | 341 | routeThreadRef |
| 342 | ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen | 342 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen |
| 343 | : false, | 343 | : false, |
| 344 | ); | 344 | ); |
| 345 | 345 |
apps/web/src/components/DiffPanel.tsx+19-95| ... | @@ -1,6 +1,4 @@ | ... | @@ -1,6 +1,4 @@ |
| 1 | import { parsePatchFiles } from "@pierre/diffs"; | 1 | import { FileDiff, Virtualizer } from "@pierre/diffs/react"; |
| 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; | ||
| 3 | import { useQuery } from "@tanstack/react-query"; | ||
| 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; | 2 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 3 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 6 | import type { TurnId } from "@t3tools/contracts"; | 4 | import type { TurnId } from "@t3tools/contracts"; |
| ... | @@ -22,15 +20,20 @@ import { | ... | @@ -22,15 +20,20 @@ import { |
| 22 | useState, | 20 | useState, |
| 23 | } from "react"; | 21 | } from "react"; |
| 24 | import { openInPreferredEditor } from "../editorPreferences"; | 22 | import { openInPreferredEditor } from "../editorPreferences"; |
| 25 | import { useGitStatus } from "~/lib/gitStatusState"; | 23 | import { useCheckpointDiff } from "~/lib/checkpointDiffState"; |
| 26 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 24 | import { useVcsStatus } from "~/lib/vcsStatusState"; |
| 27 | import { cn } from "~/lib/utils"; | 25 | import { cn } from "~/lib/utils"; |
| 28 | import { readLocalApi } from "../localApi"; | 26 | import { readLocalApi } from "../localApi"; |
| 29 | import { resolvePathLinkTarget } from "../terminal-links"; | 27 | import { resolvePathLinkTarget } from "../terminal-links"; |
| 30 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 28 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 31 | import { useTheme } from "../hooks/useTheme"; | 29 | import { useTheme } from "../hooks/useTheme"; |
| 32 | import { buildPatchCacheKey } from "../lib/diffRendering"; | 30 | import { |
| 33 | import { resolveDiffThemeName } from "../lib/diffRendering"; | 31 | buildFileDiffRenderKey, |
| 32 | getDiffCollapseIconClassName, | ||
| 33 | getRenderablePatch, | ||
| 34 | resolveDiffThemeName, | ||
| 35 | resolveFileDiffPath, | ||
| 36 | } from "../lib/diffRendering"; | ||
| 34 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 37 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 35 | import { selectProjectByRef, useStore } from "../store"; | 38 | import { selectProjectByRef, useStore } from "../store"; |
| 36 | import { createThreadSelectorByRef } from "../storeSelectors"; | 39 | import { createThreadSelectorByRef } from "../storeSelectors"; |
| ... | @@ -107,76 +110,6 @@ const DIFF_PANEL_UNSAFE_CSS = ` | ... | @@ -107,76 +110,6 @@ const DIFF_PANEL_UNSAFE_CSS = ` |
| 107 | } | 110 | } |
| 108 | `; | 111 | `; |
| 109 | 112 | ||
| 110 | type RenderablePatch = | ||
| 111 | | { | ||
| 112 | kind: "files"; | ||
| 113 | files: FileDiffMetadata[]; | ||
| 114 | } | ||
| 115 | | { | ||
| 116 | kind: "raw"; | ||
| 117 | text: string; | ||
| 118 | reason: string; | ||
| 119 | }; | ||
| 120 | |||
| 121 | function getRenderablePatch( | ||
| 122 | patch: string | undefined, | ||
| 123 | cacheScope = "diff-panel", | ||
| 124 | ): RenderablePatch | null { | ||
| 125 | if (!patch) return null; | ||
| 126 | const normalizedPatch = patch.trim(); | ||
| 127 | if (normalizedPatch.length === 0) return null; | ||
| 128 | |||
| 129 | try { | ||
| 130 | const parsedPatches = parsePatchFiles( | ||
| 131 | normalizedPatch, | ||
| 132 | buildPatchCacheKey(normalizedPatch, cacheScope), | ||
| 133 | ); | ||
| 134 | const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); | ||
| 135 | if (files.length > 0) { | ||
| 136 | return { kind: "files", files }; | ||
| 137 | } | ||
| 138 | |||
| 139 | return { | ||
| 140 | kind: "raw", | ||
| 141 | text: normalizedPatch, | ||
| 142 | reason: "Unsupported diff format. Showing raw patch.", | ||
| 143 | }; | ||
| 144 | } catch { | ||
| 145 | return { | ||
| 146 | kind: "raw", | ||
| 147 | text: normalizedPatch, | ||
| 148 | reason: "Failed to parse patch. Showing raw patch.", | ||
| 149 | }; | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { | ||
| 154 | const raw = fileDiff.name ?? fileDiff.prevName ?? ""; | ||
| 155 | if (raw.startsWith("a/") || raw.startsWith("b/")) { | ||
| 156 | return raw.slice(2); | ||
| 157 | } | ||
| 158 | return raw; | ||
| 159 | } | ||
| 160 | |||
| 161 | function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { | ||
| 162 | return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; | ||
| 163 | } | ||
| 164 | |||
| 165 | function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string { | ||
| 166 | switch (fileDiff.type) { | ||
| 167 | case "new": | ||
| 168 | return "text-[var(--diffs-addition-base)]"; | ||
| 169 | case "deleted": | ||
| 170 | return "text-[var(--diffs-deletion-base)]"; | ||
| 171 | case "change": | ||
| 172 | case "rename-pure": | ||
| 173 | case "rename-changed": | ||
| 174 | return "text-[var(--diffs-modified-base)]"; | ||
| 175 | default: | ||
| 176 | return "text-muted-foreground/80"; | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | interface DiffPanelProps { | 113 | interface DiffPanelProps { |
| 181 | mode?: DiffPanelMode; | 114 | mode?: DiffPanelMode; |
| 182 | } | 115 | } |
| ... | @@ -218,7 +151,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -218,7 +151,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 218 | : undefined, | 151 | : undefined, |
| 219 | ); | 152 | ); |
| 220 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; | 153 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 221 | const gitStatusQuery = useGitStatus({ | 154 | const gitStatusQuery = useVcsStatus({ |
| 222 | environmentId: activeThread?.environmentId ?? null, | 155 | environmentId: activeThread?.environmentId ?? null, |
| 223 | cwd: activeCwd ?? null, | 156 | cwd: activeCwd ?? null, |
| 224 | }); | 157 | }); |
| ... | @@ -294,30 +227,21 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -294,30 +227,21 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 294 | } | 227 | } |
| 295 | return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; | 228 | return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; |
| 296 | }, [orderedTurnDiffSummaries, selectedTurn]); | 229 | }, [orderedTurnDiffSummaries, selectedTurn]); |
| 297 | const activeCheckpointDiffQuery = useQuery( | 230 | const activeCheckpointDiff = useCheckpointDiff( |
| 298 | checkpointDiffQueryOptions({ | 231 | { |
| 299 | environmentId: activeThread?.environmentId ?? null, | 232 | environmentId: activeThread?.environmentId ?? null, |
| 300 | threadId: activeThreadId, | 233 | threadId: activeThreadId, |
| 301 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, | 234 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, |
| 302 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, | 235 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, |
| 303 | ignoreWhitespace: diffIgnoreWhitespace, | 236 | ignoreWhitespace: diffIgnoreWhitespace, |
| 304 | cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, | 237 | cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, |
| 305 | enabled: isGitRepo, | 238 | }, |
| 306 | }), | 239 | { enabled: isGitRepo }, |
| 307 | ); | 240 | ); |
| 308 | const selectedTurnCheckpointDiff = selectedTurn | 241 | const selectedTurnCheckpointDiff = selectedTurn ? activeCheckpointDiff.data?.diff : undefined; |
| 309 | ? activeCheckpointDiffQuery.data?.diff | 242 | const conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiff.data?.diff; |
| 310 | : undefined; | 243 | const isLoadingCheckpointDiff = activeCheckpointDiff.isPending; |
| 311 | const conversationCheckpointDiff = selectedTurn | 244 | const checkpointDiffError = activeCheckpointDiff.error; |
| 312 | ? undefined | ||
| 313 | : activeCheckpointDiffQuery.data?.diff; | ||
| 314 | const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading; | ||
| 315 | const checkpointDiffError = | ||
| 316 | activeCheckpointDiffQuery.error instanceof Error | ||
| 317 | ? activeCheckpointDiffQuery.error.message | ||
| 318 | : activeCheckpointDiffQuery.error | ||
| 319 | ? "Failed to load checkpoint diff." | ||
| 320 | : null; | ||
| 321 | 245 | ||
| 322 | const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; | 246 | const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; |
| 323 | const hasResolvedPatch = typeof selectedPatch === "string"; | 247 | const hasResolvedPatch = typeof selectedPatch === "string"; |
apps/web/src/components/GitActionsControl.browser.tsx+41-58| ... | @@ -26,9 +26,9 @@ const { | ... | @@ -26,9 +26,9 @@ const { |
| 26 | activeRunStackedActionDeferredRef, | 26 | activeRunStackedActionDeferredRef, |
| 27 | activeDraftThreadRef, | 27 | activeDraftThreadRef, |
| 28 | hasServerThreadRef, | 28 | hasServerThreadRef, |
| 29 | invalidateGitQueriesSpy, | 29 | invalidateSourceControlStateSpy, |
| 30 | refreshGitStatusSpy, | 30 | refreshVcsStatusSpy, |
| 31 | runStackedActionMutateAsyncSpy, | 31 | runStackedActionSpy, |
| 32 | setDraftThreadContextSpy, | 32 | setDraftThreadContextSpy, |
| 33 | setThreadBranchSpy, | 33 | setThreadBranchSpy, |
| 34 | toastAddSpy, | 34 | toastAddSpy, |
| ... | @@ -39,9 +39,9 @@ const { | ... | @@ -39,9 +39,9 @@ const { |
| 39 | activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() }, | 39 | activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() }, |
| 40 | activeDraftThreadRef: { current: null as unknown }, | 40 | activeDraftThreadRef: { current: null as unknown }, |
| 41 | hasServerThreadRef: { current: true }, | 41 | hasServerThreadRef: { current: true }, |
| 42 | invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), | 42 | invalidateSourceControlStateSpy: vi.fn(() => Promise.resolve()), |
| 43 | refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), | 43 | refreshVcsStatusSpy: vi.fn(() => Promise.resolve(null)), |
| 44 | runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), | 44 | runStackedActionSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), |
| 45 | setDraftThreadContextSpy: vi.fn(), | 45 | setDraftThreadContextSpy: vi.fn(), |
| 46 | setThreadBranchSpy: vi.fn(), | 46 | setThreadBranchSpy: vi.fn(), |
| 47 | toastAddSpy: vi.fn(() => "toast-1"), | 47 | toastAddSpy: vi.fn(() => "toast-1"), |
| ... | @@ -50,39 +50,6 @@ const { | ... | @@ -50,39 +50,6 @@ const { |
| 50 | toastUpdateSpy: vi.fn(), | 50 | toastUpdateSpy: vi.fn(), |
| 51 | })); | 51 | })); |
| 52 | 52 | ||
| 53 | vi.mock("@tanstack/react-query", async () => { | ||
| 54 | const actual = | ||
| 55 | await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query"); | ||
| 56 | |||
| 57 | return { | ||
| 58 | ...actual, | ||
| 59 | useIsMutating: vi.fn(() => 0), | ||
| 60 | useMutation: vi.fn((options: { __kind?: string }) => { | ||
| 61 | if (options.__kind === "run-stacked-action") { | ||
| 62 | return { | ||
| 63 | mutateAsync: runStackedActionMutateAsyncSpy, | ||
| 64 | isPending: false, | ||
| 65 | }; | ||
| 66 | } | ||
| 67 | |||
| 68 | if (options.__kind === "pull") { | ||
| 69 | return { | ||
| 70 | mutateAsync: vi.fn(), | ||
| 71 | isPending: false, | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | return { | ||
| 76 | mutate: vi.fn(), | ||
| 77 | mutateAsync: vi.fn(), | ||
| 78 | isPending: false, | ||
| 79 | }; | ||
| 80 | }), | ||
| 81 | useQuery: vi.fn(() => ({ data: null, error: null })), | ||
| 82 | useQueryClient: vi.fn(() => ({})), | ||
| 83 | }; | ||
| 84 | }); | ||
| 85 | |||
| 86 | vi.mock("~/components/ui/toast", () => ({ | 53 | vi.mock("~/components/ui/toast", () => ({ |
| 87 | toastManager: { | 54 | toastManager: { |
| 88 | add: toastAddSpy, | 55 | add: toastAddSpy, |
| ... | @@ -97,23 +64,39 @@ vi.mock("~/editorPreferences", () => ({ | ... | @@ -97,23 +64,39 @@ vi.mock("~/editorPreferences", () => ({ |
| 97 | openInPreferredEditor: vi.fn(), | 64 | openInPreferredEditor: vi.fn(), |
| 98 | })); | 65 | })); |
| 99 | 66 | ||
| 100 | vi.mock("~/lib/gitReactQuery", () => ({ | 67 | vi.mock("~/lib/sourceControlActions", () => ({ |
| 101 | gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), | 68 | invalidateSourceControlState: invalidateSourceControlStateSpy, |
| 102 | gitMutationKeys: { | 69 | useGitStackedAction: vi.fn(() => ({ |
| 103 | publishRepository: vi.fn(() => ["publish-repository"]), | 70 | error: null, |
| 104 | pull: vi.fn(() => ["pull"]), | 71 | isPending: false, |
| 105 | runStackedAction: vi.fn(() => ["run-stacked-action"]), | 72 | resetError: vi.fn(), |
| 106 | }, | 73 | run: runStackedActionSpy, |
| 107 | gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), | 74 | })), |
| 108 | gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), | 75 | useSourceControlActionRunning: vi.fn(() => false), |
| 109 | invalidateGitQueries: invalidateGitQueriesSpy, | 76 | useSourceControlPublishRepositoryAction: vi.fn(() => ({ |
| 110 | sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })), | 77 | error: null, |
| 78 | isPending: false, | ||
| 79 | resetError: vi.fn(), | ||
| 80 | run: vi.fn(), | ||
| 81 | })), | ||
| 82 | useVcsInitAction: vi.fn(() => ({ | ||
| 83 | error: null, | ||
| 84 | isPending: false, | ||
| 85 | resetError: vi.fn(), | ||
| 86 | run: vi.fn(), | ||
| 87 | })), | ||
| 88 | useVcsPullAction: vi.fn(() => ({ | ||
| 89 | error: null, | ||
| 90 | isPending: false, | ||
| 91 | resetError: vi.fn(), | ||
| 92 | run: vi.fn(), | ||
| 93 | })), | ||
| 111 | })); | 94 | })); |
| 112 | 95 | ||
| 113 | vi.mock("~/lib/gitStatusState", () => ({ | 96 | vi.mock("~/lib/vcsStatusState", () => ({ |
| 114 | refreshGitStatus: refreshGitStatusSpy, | 97 | refreshVcsStatus: refreshVcsStatusSpy, |
| 115 | resetGitStatusStateForTests: () => undefined, | 98 | resetVcsStatusStateForTests: () => undefined, |
| 116 | useGitStatus: vi.fn(() => ({ | 99 | useVcsStatus: vi.fn(() => ({ |
| 117 | data: { | 100 | data: { |
| 118 | isRepo: true, | 101 | isRepo: true, |
| 119 | sourceControlProvider: { | 102 | sourceControlProvider: { |
| ... | @@ -379,14 +362,14 @@ describe("GitActionsControl thread-scoped progress toast", () => { | ... | @@ -379,14 +362,14 @@ describe("GitActionsControl thread-scoped progress toast", () => { |
| 379 | visibilityState = "visible"; | 362 | visibilityState = "visible"; |
| 380 | document.dispatchEvent(new Event("visibilitychange")); | 363 | document.dispatchEvent(new Event("visibilitychange")); |
| 381 | 364 | ||
| 382 | expect(refreshGitStatusSpy).not.toHaveBeenCalled(); | 365 | expect(refreshVcsStatusSpy).not.toHaveBeenCalled(); |
| 383 | 366 | ||
| 384 | await vi.advanceTimersByTimeAsync(249); | 367 | await vi.advanceTimersByTimeAsync(249); |
| 385 | expect(refreshGitStatusSpy).not.toHaveBeenCalled(); | 368 | expect(refreshVcsStatusSpy).not.toHaveBeenCalled(); |
| 386 | 369 | ||
| 387 | await vi.advanceTimersByTimeAsync(1); | 370 | await vi.advanceTimersByTimeAsync(1); |
| 388 | expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); | 371 | expect(refreshVcsStatusSpy).toHaveBeenCalledTimes(1); |
| 389 | expect(refreshGitStatusSpy).toHaveBeenCalledWith({ | 372 | expect(refreshVcsStatusSpy).toHaveBeenCalledWith({ |
| 390 | environmentId: ENVIRONMENT_A, | 373 | environmentId: ENVIRONMENT_A, |
| 391 | cwd: GIT_CWD, | 374 | cwd: GIT_CWD, |
| 392 | }); | 375 | }); |
apps/web/src/components/GitActionsControl.tsx+65-79| ... | @@ -10,7 +10,6 @@ import type { | ... | @@ -10,7 +10,6 @@ import type { |
| 10 | SourceControlRepositoryVisibility, | 10 | SourceControlRepositoryVisibility, |
| 11 | VcsStatusResult, | 11 | VcsStatusResult, |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; | ||
| 14 | import { useNavigate } from "@tanstack/react-router"; | 13 | import { useNavigate } from "@tanstack/react-router"; |
| 15 | import * as Option from "effect/Option"; | 14 | import * as Option from "effect/Option"; |
| 16 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | 15 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| ... | @@ -65,13 +64,13 @@ import { stackedThreadToast, toastManager, type ThreadToastData } from "~/compon | ... | @@ -65,13 +64,13 @@ import { stackedThreadToast, toastManager, type ThreadToastData } from "~/compon |
| 65 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 64 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 66 | import { openInPreferredEditor } from "~/editorPreferences"; | 65 | import { openInPreferredEditor } from "~/editorPreferences"; |
| 67 | import { | 66 | import { |
| 68 | gitInitMutationOptions, | 67 | useGitStackedAction, |
| 69 | gitMutationKeys, | 68 | useSourceControlActionRunning, |
| 70 | gitPullMutationOptions, | 69 | useSourceControlPublishRepositoryAction, |
| 71 | gitRunStackedActionMutationOptions, | 70 | useVcsInitAction, |
| 72 | sourceControlPublishRepositoryMutationOptions, | 71 | useVcsPullAction, |
| 73 | } from "~/lib/gitReactQuery"; | 72 | } from "~/lib/sourceControlActions"; |
| 74 | import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; | 73 | import { refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState"; |
| 75 | import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; | 74 | import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; |
| 76 | import { newCommandId, randomUUID } from "~/lib/utils"; | 75 | import { newCommandId, randomUUID } from "~/lib/utils"; |
| 77 | import { resolvePathLinkTarget } from "~/terminal-links"; | 76 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| ... | @@ -128,6 +127,7 @@ interface RunGitActionWithToastInput { | ... | @@ -128,6 +127,7 @@ interface RunGitActionWithToastInput { |
| 128 | } | 127 | } |
| 129 | 128 | ||
| 130 | const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; | 129 | const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; |
| 130 | const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; | ||
| 131 | 131 | ||
| 132 | const PUBLISH_PROVIDER_OPTIONS = [ | 132 | const PUBLISH_PROVIDER_OPTIONS = [ |
| 133 | { | 133 | { |
| ... | @@ -346,7 +346,6 @@ interface PublishRepositoryDialogProps { | ... | @@ -346,7 +346,6 @@ interface PublishRepositoryDialogProps { |
| 346 | } | 346 | } |
| 347 | 347 | ||
| 348 | function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | 348 | function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 349 | const queryClient = useQueryClient(); | ||
| 350 | const navigate = useNavigate(); | 349 | const navigate = useNavigate(); |
| 351 | const sourceControlDiscovery = useSourceControlDiscovery(); | 350 | const sourceControlDiscovery = useSourceControlDiscovery(); |
| 352 | const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github"); | 351 | const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github"); |
| ... | @@ -362,13 +361,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -362,13 +361,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 362 | null, | 361 | null, |
| 363 | ); | 362 | ); |
| 364 | const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); | 363 | const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); |
| 365 | const publishRepositoryMutation = useMutation( | 364 | const sourceControlScope = useMemo( |
| 366 | sourceControlPublishRepositoryMutationOptions({ | 365 | () => ({ |
| 367 | environmentId: props.environmentId, | 366 | environmentId: props.environmentId, |
| 368 | cwd: props.gitCwd, | 367 | cwd: props.gitCwd, |
| 369 | queryClient, | ||
| 370 | }), | 368 | }), |
| 369 | [props.environmentId, props.gitCwd], | ||
| 371 | ); | 370 | ); |
| 371 | const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); | ||
| 372 | const publishAccountByProvider = useMemo(() => { | 372 | const publishAccountByProvider = useMemo(() => { |
| 373 | const accounts: Record<PublishProviderKind, string | null> = { | 373 | const accounts: Record<PublishProviderKind, string | null> = { |
| 374 | github: null, | 374 | github: null, |
| ... | @@ -435,13 +435,13 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -435,13 +435,13 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 435 | 435 | ||
| 436 | const canSubmitPublishRepository = useMemo(() => { | 436 | const canSubmitPublishRepository = useMemo(() => { |
| 437 | if (!selectedPublishProviderReadiness.ready) return false; | 437 | if (!selectedPublishProviderReadiness.ready) return false; |
| 438 | if (publishRepositoryMutation.isPending) return false; | 438 | if (publishRepositoryAction.isPending) return false; |
| 439 | const repositoryParts = publishRepository.trim().split("/"); | 439 | const repositoryParts = publishRepository.trim().split("/"); |
| 440 | const owner = repositoryParts[0]?.trim() ?? ""; | 440 | const owner = repositoryParts[0]?.trim() ?? ""; |
| 441 | const rest = repositoryParts.slice(1); | 441 | const rest = repositoryParts.slice(1); |
| 442 | const name = rest.join("/").trim(); | 442 | const name = rest.join("/").trim(); |
| 443 | return owner.length > 0 && name.length > 0; | 443 | return owner.length > 0 && name.length > 0; |
| 444 | }, [publishRepository, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]); | 444 | }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]); |
| 445 | 445 | ||
| 446 | useEffect(() => { | 446 | useEffect(() => { |
| 447 | if (!props.open) { | 447 | if (!props.open) { |
| ... | @@ -465,8 +465,8 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -465,8 +465,8 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 465 | 465 | ||
| 466 | setPublishError(null); | 466 | setPublishError(null); |
| 467 | 467 | ||
| 468 | void publishRepositoryMutation | 468 | void publishRepositoryAction |
| 469 | .mutateAsync({ | 469 | .run({ |
| 470 | provider: publishProvider, | 470 | provider: publishProvider, |
| 471 | repository: publishRepository.trim(), | 471 | repository: publishRepository.trim(), |
| 472 | visibility: publishVisibility, | 472 | visibility: publishVisibility, |
| ... | @@ -478,7 +478,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -478,7 +478,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 478 | setPublishResult(result); | 478 | setPublishResult(result); |
| 479 | setPublishWizardStep(2); | 479 | setPublishWizardStep(2); |
| 480 | }); | 480 | }); |
| 481 | void refreshGitStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( | 481 | void refreshVcsStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( |
| 482 | () => undefined, | 482 | () => undefined, |
| 483 | ); | 483 | ); |
| 484 | }) | 484 | }) |
| ... | @@ -493,7 +493,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -493,7 +493,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 493 | publishProvider, | 493 | publishProvider, |
| 494 | publishRemoteName, | 494 | publishRemoteName, |
| 495 | publishRepository, | 495 | publishRepository, |
| 496 | publishRepositoryMutation, | 496 | publishRepositoryAction, |
| 497 | publishVisibility, | 497 | publishVisibility, |
| 498 | ]); | 498 | ]); |
| 499 | 499 | ||
| ... | @@ -689,7 +689,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -689,7 +689,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 689 | } | 689 | } |
| 690 | }} | 690 | }} |
| 691 | placeholder={publishPathPlaceholder} | 691 | placeholder={publishPathPlaceholder} |
| 692 | disabled={publishRepositoryMutation.isPending} | 692 | disabled={publishRepositoryAction.isPending} |
| 693 | className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" | 693 | className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" |
| 694 | /> | 694 | /> |
| 695 | </div> | 695 | </div> |
| ... | @@ -708,7 +708,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -708,7 +708,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 708 | setPublishVisibility(value as SourceControlRepositoryVisibility) | 708 | setPublishVisibility(value as SourceControlRepositoryVisibility) |
| 709 | } | 709 | } |
| 710 | aria-labelledby="publish-visibility-cards-label" | 710 | aria-labelledby="publish-visibility-cards-label" |
| 711 | disabled={publishRepositoryMutation.isPending} | 711 | disabled={publishRepositoryAction.isPending} |
| 712 | className="grid grid-cols-2 gap-2.5" | 712 | className="grid grid-cols-2 gap-2.5" |
| 713 | > | 713 | > |
| 714 | {[ | 714 | {[ |
| ... | @@ -780,7 +780,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -780,7 +780,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 780 | value={publishRemoteName} | 780 | value={publishRemoteName} |
| 781 | onChange={(event) => setPublishRemoteName(event.target.value)} | 781 | onChange={(event) => setPublishRemoteName(event.target.value)} |
| 782 | placeholder="origin" | 782 | placeholder="origin" |
| 783 | disabled={publishRepositoryMutation.isPending} | 783 | disabled={publishRepositoryAction.isPending} |
| 784 | /> | 784 | /> |
| 785 | </label> | 785 | </label> |
| 786 | <div className="space-y-1.5"> | 786 | <div className="space-y-1.5"> |
| ... | @@ -796,7 +796,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -796,7 +796,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 796 | setPublishProtocol(value as SourceControlCloneProtocol) | 796 | setPublishProtocol(value as SourceControlCloneProtocol) |
| 797 | } | 797 | } |
| 798 | aria-labelledby="publish-protocol-label" | 798 | aria-labelledby="publish-protocol-label" |
| 799 | disabled={publishRepositoryMutation.isPending} | 799 | disabled={publishRepositoryAction.isPending} |
| 800 | className="grid grid-cols-2 gap-2" | 800 | className="grid grid-cols-2 gap-2" |
| 801 | > | 801 | > |
| 802 | {(["ssh", "https"] as const).map((value) => { | 802 | {(["ssh", "https"] as const).map((value) => { |
| ... | @@ -823,7 +823,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -823,7 +823,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 823 | ) : null} | 823 | ) : null} |
| 824 | </div> | 824 | </div> |
| 825 | 825 | ||
| 826 | {publishRepositoryMutation.isPending ? ( | 826 | {publishRepositoryAction.isPending ? ( |
| 827 | <div | 827 | <div |
| 828 | role="status" | 828 | role="status" |
| 829 | aria-live="polite" | 829 | aria-live="polite" |
| ... | @@ -833,7 +833,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -833,7 +833,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 833 | Publishing repository to {publishProviderLabel}... | 833 | Publishing repository to {publishProviderLabel}... |
| 834 | </div> | 834 | </div> |
| 835 | ) : null} | 835 | ) : null} |
| 836 | {publishError && !publishRepositoryMutation.isPending ? ( | 836 | {publishError && !publishRepositoryAction.isPending ? ( |
| 837 | <div | 837 | <div |
| 838 | role="alert" | 838 | role="alert" |
| 839 | className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive" | 839 | className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive" |
| ... | @@ -901,7 +901,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -901,7 +901,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 901 | <Button | 901 | <Button |
| 902 | variant="outline" | 902 | variant="outline" |
| 903 | size="sm" | 903 | size="sm" |
| 904 | disabled={publishRepositoryMutation.isPending} | 904 | disabled={publishRepositoryAction.isPending} |
| 905 | onClick={() => { | 905 | onClick={() => { |
| 906 | if (publishWizardStep === 0) { | 906 | if (publishWizardStep === 0) { |
| 907 | handleOpenChange(false); | 907 | handleOpenChange(false); |
| ... | @@ -926,7 +926,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -926,7 +926,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 926 | disabled={!canSubmitPublishRepository} | 926 | disabled={!canSubmitPublishRepository} |
| 927 | onClick={submitPublishRepository} | 927 | onClick={submitPublishRepository} |
| 928 | > | 928 | > |
| 929 | {publishRepositoryMutation.isPending ? ( | 929 | {publishRepositoryAction.isPending ? ( |
| 930 | <> | 930 | <> |
| 931 | <Spinner className="size-3.5" aria-hidden /> | 931 | <Spinner className="size-3.5" aria-hidden /> |
| 932 | Publishing... | 932 | Publishing... |
| ... | @@ -969,7 +969,6 @@ export default function GitActionsControl({ | ... | @@ -969,7 +969,6 @@ export default function GitActionsControl({ |
| 969 | ); | 969 | ); |
| 970 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 970 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 971 | const setThreadBranch = useStore((store) => store.setThreadBranch); | 971 | const setThreadBranch = useStore((store) => store.setThreadBranch); |
| 972 | const queryClient = useQueryClient(); | ||
| 973 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); | 972 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); |
| 974 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); | 973 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 975 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); | 974 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); |
| ... | @@ -978,6 +977,10 @@ export default function GitActionsControl({ | ... | @@ -978,6 +977,10 @@ export default function GitActionsControl({ |
| 978 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = | 977 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = |
| 979 | useState<PendingDefaultBranchAction | null>(null); | 978 | useState<PendingDefaultBranchAction | null>(null); |
| 980 | const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null); | 979 | const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null); |
| 980 | const sourceControlScope = useMemo( | ||
| 981 | () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }), | ||
| 982 | [activeEnvironmentId, gitCwd], | ||
| 983 | ); | ||
| 981 | let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>; | 984 | let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>; |
| 982 | 985 | ||
| 983 | const updateActiveProgressToast = useCallback(() => { | 986 | const updateActiveProgressToast = useCallback(() => { |
| ... | @@ -1054,7 +1057,7 @@ export default function GitActionsControl({ | ... | @@ -1054,7 +1057,7 @@ export default function GitActionsControl({ |
| 1054 | [persistThreadBranchSync], | 1057 | [persistThreadBranchSync], |
| 1055 | ); | 1058 | ); |
| 1056 | 1059 | ||
| 1057 | const { data: gitStatus = null, error: gitStatusError } = useGitStatus({ | 1060 | const { data: gitStatus = null, error: gitStatusError } = useVcsStatus({ |
| 1058 | environmentId: activeEnvironmentId, | 1061 | environmentId: activeEnvironmentId, |
| 1059 | cwd: gitCwd, | 1062 | cwd: gitCwd, |
| 1060 | }); | 1063 | }); |
| ... | @@ -1074,32 +1077,13 @@ export default function GitActionsControl({ | ... | @@ -1074,32 +1077,13 @@ export default function GitActionsControl({ |
| 1074 | const allSelected = excludedFiles.size === 0; | 1077 | const allSelected = excludedFiles.size === 0; |
| 1075 | const noneSelected = selectedFiles.length === 0; | 1078 | const noneSelected = selectedFiles.length === 0; |
| 1076 | 1079 | ||
| 1077 | const initMutation = useMutation( | 1080 | const initAction = useVcsInitAction(sourceControlScope); |
| 1078 | gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), | 1081 | const runImmediateGitAction = useGitStackedAction(sourceControlScope); |
| 1079 | ); | 1082 | const pullAction = useVcsPullAction(sourceControlScope); |
| 1080 | 1083 | const isGitActionRunning = useSourceControlActionRunning( | |
| 1081 | const runImmediateGitActionMutation = useMutation( | 1084 | sourceControlScope, |
| 1082 | gitRunStackedActionMutationOptions({ | 1085 | RUNNING_SOURCE_CONTROL_ACTIONS, |
| 1083 | environmentId: activeEnvironmentId, | ||
| 1084 | cwd: gitCwd, | ||
| 1085 | queryClient, | ||
| 1086 | }), | ||
| 1087 | ); | 1086 | ); |
| 1088 | const pullMutation = useMutation( | ||
| 1089 | gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), | ||
| 1090 | ); | ||
| 1091 | |||
| 1092 | const isRunStackedActionRunning = | ||
| 1093 | useIsMutating({ | ||
| 1094 | mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd), | ||
| 1095 | }) > 0; | ||
| 1096 | const isPullRunning = | ||
| 1097 | useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; | ||
| 1098 | const isPublishRunning = | ||
| 1099 | useIsMutating({ | ||
| 1100 | mutationKey: gitMutationKeys.publishRepository(activeEnvironmentId, gitCwd), | ||
| 1101 | }) > 0; | ||
| 1102 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning || isPublishRunning; | ||
| 1103 | const isSelectingWorktreeBase = | 1087 | const isSelectingWorktreeBase = |
| 1104 | !activeServerThread && | 1088 | !activeServerThread && |
| 1105 | activeDraftThread?.envMode === "worktree" && | 1089 | activeDraftThread?.envMode === "worktree" && |
| ... | @@ -1178,7 +1162,7 @@ export default function GitActionsControl({ | ... | @@ -1178,7 +1162,7 @@ export default function GitActionsControl({ |
| 1178 | } | 1162 | } |
| 1179 | refreshTimeout = window.setTimeout(() => { | 1163 | refreshTimeout = window.setTimeout(() => { |
| 1180 | refreshTimeout = null; | 1164 | refreshTimeout = null; |
| 1181 | void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( | 1165 | void refreshVcsStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( |
| 1182 | () => undefined, | 1166 | () => undefined, |
| 1183 | ); | 1167 | ); |
| 1184 | }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); | 1168 | }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); |
| ... | @@ -1376,7 +1360,7 @@ export default function GitActionsControl({ | ... | @@ -1376,7 +1360,7 @@ export default function GitActionsControl({ |
| 1376 | updateActiveProgressToast(); | 1360 | updateActiveProgressToast(); |
| 1377 | }; | 1361 | }; |
| 1378 | 1362 | ||
| 1379 | const promise = runImmediateGitActionMutation.mutateAsync({ | 1363 | const promise = runImmediateGitAction.run({ |
| 1380 | actionId, | 1364 | actionId, |
| 1381 | action, | 1365 | action, |
| 1382 | ...(commitMessage ? { commitMessage } : {}), | 1366 | ...(commitMessage ? { commitMessage } : {}), |
| ... | @@ -1516,26 +1500,26 @@ export default function GitActionsControl({ | ... | @@ -1516,26 +1500,26 @@ export default function GitActionsControl({ |
| 1516 | return; | 1500 | return; |
| 1517 | } | 1501 | } |
| 1518 | if (quickAction.kind === "run_pull") { | 1502 | if (quickAction.kind === "run_pull") { |
| 1519 | const promise = pullMutation.mutateAsync(); | 1503 | const promise = pullAction.run(); |
| 1520 | void toastManager.promise< | 1504 | void toastManager.promise<Awaited<ReturnType<typeof pullAction.run>>, ThreadToastData>( |
| 1521 | Awaited<ReturnType<typeof pullMutation.mutateAsync>>, | 1505 | promise, |
| 1522 | ThreadToastData | 1506 | { |
| 1523 | >(promise, { | 1507 | loading: { title: "Pulling...", data: threadToastData }, |
| 1524 | loading: { title: "Pulling...", data: threadToastData }, | 1508 | success: (result) => ({ |
| 1525 | success: (result) => ({ | 1509 | title: result.status === "pulled" ? "Pulled" : "Already up to date", |
| 1526 | title: result.status === "pulled" ? "Pulled" : "Already up to date", | 1510 | description: |
| 1527 | description: | 1511 | result.status === "pulled" |
| 1528 | result.status === "pulled" | 1512 | ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` |
| 1529 | ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` | 1513 | : `${result.refName} is already synchronized.`, |
| 1530 | : `${result.refName} is already synchronized.`, | 1514 | data: threadToastData, |
| 1531 | data: threadToastData, | 1515 | }), |
| 1532 | }), | 1516 | error: (err) => ({ |
| 1533 | error: (err) => ({ | 1517 | title: "Pull failed", |
| 1534 | title: "Pull failed", | 1518 | description: err instanceof Error ? err.message : "An error occurred.", |
| 1535 | description: err instanceof Error ? err.message : "An error occurred.", | 1519 | data: threadToastData, |
| 1536 | data: threadToastData, | 1520 | }), |
| 1537 | }), | 1521 | }, |
| 1538 | }); | 1522 | ); |
| 1539 | void promise.catch(() => undefined); | 1523 | void promise.catch(() => undefined); |
| 1540 | return; | 1524 | return; |
| 1541 | } | 1525 | } |
| ... | @@ -1622,10 +1606,12 @@ export default function GitActionsControl({ | ... | @@ -1622,10 +1606,12 @@ export default function GitActionsControl({ |
| 1622 | <Button | 1606 | <Button |
| 1623 | variant="outline" | 1607 | variant="outline" |
| 1624 | size="xs" | 1608 | size="xs" |
| 1625 | disabled={initMutation.isPending} | 1609 | disabled={initAction.isPending} |
| 1626 | onClick={() => initMutation.mutate()} | 1610 | onClick={() => { |
| 1611 | void initAction.run(); | ||
| 1612 | }} | ||
| 1627 | > | 1613 | > |
| 1628 | {initMutation.isPending ? "Initializing..." : "Initialize Git"} | 1614 | {initAction.isPending ? "Initializing..." : "Initialize Git"} |
| 1629 | </Button> | 1615 | </Button> |
| 1630 | ) : ( | 1616 | ) : ( |
| 1631 | <Group aria-label="Git actions" className="shrink-0"> | 1617 | <Group aria-label="Git actions" className="shrink-0"> |
| ... | @@ -1671,7 +1657,7 @@ export default function GitActionsControl({ | ... | @@ -1671,7 +1657,7 @@ export default function GitActionsControl({ |
| 1671 | <Menu | 1657 | <Menu |
| 1672 | onOpenChange={(open) => { | 1658 | onOpenChange={(open) => { |
| 1673 | if (open) { | 1659 | if (open) { |
| 1674 | void refreshGitStatus({ | 1660 | void refreshVcsStatus({ |
| 1675 | environmentId: activeEnvironmentId, | 1661 | environmentId: activeEnvironmentId, |
| 1676 | cwd: gitCwd, | 1662 | cwd: gitCwd, |
| 1677 | }).catch(() => undefined); | 1663 | }).catch(() => undefined); |
apps/web/src/components/KeybindingsToast.browser.tsx+32-6| ... | @@ -33,12 +33,38 @@ import { useStore } from "../store"; | ... | @@ -33,12 +33,38 @@ import { useStore } from "../store"; |
| 33 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; | 33 | import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; |
| 34 | import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; | 34 | import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; |
| 35 | 35 | ||
| 36 | vi.mock("../lib/gitStatusState", () => ({ | 36 | vi.mock("../lib/vcsStatusState", () => { |
| 37 | useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), | 37 | const status = { |
| 38 | useGitStatuses: () => new Map(), | 38 | data: { |
| 39 | refreshGitStatus: () => Promise.resolve(null), | 39 | isRepo: true, |
| 40 | resetGitStatusStateForTests: () => undefined, | 40 | sourceControlProvider: { |
| 41 | })); | 41 | kind: "github", |
| 42 | name: "GitHub", | ||
| 43 | baseUrl: "https://github.com", | ||
| 44 | }, | ||
| 45 | hasPrimaryRemote: true, | ||
| 46 | isDefaultRef: true, | ||
| 47 | refName: "main", | ||
| 48 | hasWorkingTreeChanges: false, | ||
| 49 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 50 | hasUpstream: true, | ||
| 51 | aheadCount: 0, | ||
| 52 | behindCount: 0, | ||
| 53 | pr: null, | ||
| 54 | }, | ||
| 55 | error: null, | ||
| 56 | cause: null, | ||
| 57 | isPending: false, | ||
| 58 | }; | ||
| 59 | |||
| 60 | return { | ||
| 61 | getVcsStatusSnapshot: () => status, | ||
| 62 | useVcsStatus: () => status, | ||
| 63 | useVcsStatuses: () => new Map(), | ||
| 64 | refreshVcsStatus: () => Promise.resolve(null), | ||
| 65 | resetVcsStatusStateForTests: () => undefined, | ||
| 66 | }; | ||
| 67 | }); | ||
| 42 | 68 | ||
| 43 | const THREAD_ID = "thread-kb-toast-test" as ThreadId; | 69 | const THREAD_ID = "thread-kb-toast-test" as ThreadId; |
| 44 | const PROJECT_ID = "project-1" as ProjectId; | 70 | const PROJECT_ID = "project-1" as ProjectId; |
apps/web/src/components/PullRequestThreadDialog.tsx+38-42| ... | @@ -1,13 +1,13 @@ | ... | @@ -1,13 +1,13 @@ |
| 1 | import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| 3 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 2 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | 4 | ||
| 6 | import { | 5 | import { |
| 7 | gitPreparePullRequestThreadMutationOptions, | 6 | readCachedPullRequestResolution, |
| 8 | gitResolvePullRequestQueryOptions, | 7 | usePreparePullRequestThreadAction, |
| 9 | } from "~/lib/gitReactQuery"; | 8 | usePullRequestResolution, |
| 10 | import { useGitStatus } from "~/lib/gitStatusState"; | 9 | } from "~/lib/sourceControlActions"; |
| 10 | import { useVcsStatus } from "~/lib/vcsStatusState"; | ||
| 11 | import { cn } from "~/lib/utils"; | 11 | import { cn } from "~/lib/utils"; |
| 12 | import { parsePullRequestReference } from "~/pullRequestReference"; | 12 | import { parsePullRequestReference } from "~/pullRequestReference"; |
| 13 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; | 13 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; |
| ... | @@ -43,7 +43,6 @@ export function PullRequestThreadDialog({ | ... | @@ -43,7 +43,6 @@ export function PullRequestThreadDialog({ |
| 43 | onOpenChange, | 43 | onOpenChange, |
| 44 | onPrepared, | 44 | onPrepared, |
| 45 | }: PullRequestThreadDialogProps) { | 45 | }: PullRequestThreadDialogProps) { |
| 46 | const queryClient = useQueryClient(); | ||
| 47 | const referenceInputRef = useRef<HTMLInputElement>(null); | 46 | const referenceInputRef = useRef<HTMLInputElement>(null); |
| 48 | const [reference, setReference] = useState(initialReference ?? ""); | 47 | const [reference, setReference] = useState(initialReference ?? ""); |
| 49 | const [referenceDirty, setReferenceDirty] = useState(false); | 48 | const [referenceDirty, setReferenceDirty] = useState(false); |
| ... | @@ -53,7 +52,7 @@ export function PullRequestThreadDialog({ | ... | @@ -53,7 +52,7 @@ export function PullRequestThreadDialog({ |
| 53 | { wait: 450 }, | 52 | { wait: 450 }, |
| 54 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 53 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 55 | ); | 54 | ); |
| 56 | const { data: gitStatus = null } = useGitStatus({ environmentId, cwd }); | 55 | const { data: gitStatus = null } = useVcsStatus({ environmentId, cwd }); |
| 57 | const sourceControlPresentation = useMemo( | 56 | const sourceControlPresentation = useMemo( |
| 58 | () => getSourceControlPresentation(gitStatus?.sourceControlProvider), | 57 | () => getSourceControlPresentation(gitStatus?.sourceControlProvider), |
| 59 | [gitStatus?.sourceControlProvider], | 58 | [gitStatus?.sourceControlProvider], |
| ... | @@ -81,33 +80,30 @@ export function PullRequestThreadDialog({ | ... | @@ -81,33 +80,30 @@ export function PullRequestThreadDialog({ |
| 81 | 80 | ||
| 82 | const parsedReference = parsePullRequestReference(reference); | 81 | const parsedReference = parsePullRequestReference(reference); |
| 83 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); | 82 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); |
| 84 | const resolvePullRequestQuery = useQuery( | 83 | const sourceControlScope = useMemo( |
| 85 | gitResolvePullRequestQueryOptions({ | 84 | () => ({ |
| 86 | environmentId, | 85 | environmentId, |
| 87 | cwd, | 86 | cwd, |
| 88 | reference: open ? parsedDebouncedReference : null, | ||
| 89 | }), | 87 | }), |
| 88 | [cwd, environmentId], | ||
| 90 | ); | 89 | ); |
| 90 | const pullRequestResolution = usePullRequestResolution({ | ||
| 91 | ...sourceControlScope, | ||
| 92 | reference: open ? parsedDebouncedReference : null, | ||
| 93 | }); | ||
| 91 | const cachedPullRequest = useMemo(() => { | 94 | const cachedPullRequest = useMemo(() => { |
| 92 | if (!cwd || !parsedReference) { | 95 | return ( |
| 93 | return null; | 96 | readCachedPullRequestResolution({ |
| 94 | } | 97 | ...sourceControlScope, |
| 95 | const cached = queryClient.getQueryData<GitResolvePullRequestResult>([ | 98 | reference: parsedReference, |
| 96 | "git", | 99 | })?.pullRequest ?? null |
| 97 | "pull-request", | 100 | ); |
| 98 | environmentId, | 101 | }, [parsedReference, sourceControlScope]); |
| 99 | cwd, | 102 | const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope); |
| 100 | parsedReference, | ||
| 101 | ]); | ||
| 102 | return cached?.pullRequest ?? null; | ||
| 103 | }, [cwd, environmentId, parsedReference, queryClient]); | ||
| 104 | const preparePullRequestThreadMutation = useMutation( | ||
| 105 | gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }), | ||
| 106 | ); | ||
| 107 | 103 | ||
| 108 | const liveResolvedPullRequest = | 104 | const liveResolvedPullRequest = |
| 109 | parsedReference !== null && parsedReference === parsedDebouncedReference | 105 | parsedReference !== null && parsedReference === parsedDebouncedReference |
| 110 | ? (resolvePullRequestQuery.data?.pullRequest ?? null) | 106 | ? (pullRequestResolution.data?.pullRequest ?? null) |
| 111 | : null; | 107 | : null; |
| 112 | const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest; | 108 | const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest; |
| 113 | const isResolving = | 109 | const isResolving = |
| ... | @@ -116,8 +112,8 @@ export function PullRequestThreadDialog({ | ... | @@ -116,8 +112,8 @@ export function PullRequestThreadDialog({ |
| 116 | resolvedPullRequest === null && | 112 | resolvedPullRequest === null && |
| 117 | (referenceDebouncer.state.isPending || | 113 | (referenceDebouncer.state.isPending || |
| 118 | parsedReference !== parsedDebouncedReference || | 114 | parsedReference !== parsedDebouncedReference || |
| 119 | resolvePullRequestQuery.isPending || | 115 | pullRequestResolution.isPending || |
| 120 | resolvePullRequestQuery.isFetching); | 116 | pullRequestResolution.isFetching); |
| 121 | const statusTone = useMemo(() => { | 117 | const statusTone = useMemo(() => { |
| 122 | switch (resolvedPullRequest?.state) { | 118 | switch (resolvedPullRequest?.state) { |
| 123 | case "merged": | 119 | case "merged": |
| ... | @@ -142,7 +138,7 @@ export function PullRequestThreadDialog({ | ... | @@ -142,7 +138,7 @@ export function PullRequestThreadDialog({ |
| 142 | } | 138 | } |
| 143 | setPreparingMode(mode); | 139 | setPreparingMode(mode); |
| 144 | try { | 140 | try { |
| 145 | const result = await preparePullRequestThreadMutation.mutateAsync({ | 141 | const result = await preparePullRequestThreadAction.run({ |
| 146 | reference: parsedReference, | 142 | reference: parsedReference, |
| 147 | mode, | 143 | mode, |
| 148 | ...(mode === "worktree" ? { threadId } : {}), | 144 | ...(mode === "worktree" ? { threadId } : {}), |
| ... | @@ -161,7 +157,7 @@ export function PullRequestThreadDialog({ | ... | @@ -161,7 +157,7 @@ export function PullRequestThreadDialog({ |
| 161 | onOpenChange, | 157 | onOpenChange, |
| 162 | onPrepared, | 158 | onPrepared, |
| 163 | parsedReference, | 159 | parsedReference, |
| 164 | preparePullRequestThreadMutation, | 160 | preparePullRequestThreadAction, |
| 165 | resolvedPullRequest, | 161 | resolvedPullRequest, |
| 166 | threadId, | 162 | threadId, |
| 167 | ], | 163 | ], |
| ... | @@ -176,13 +172,13 @@ export function PullRequestThreadDialog({ | ... | @@ -176,13 +172,13 @@ export function PullRequestThreadDialog({ |
| 176 | : null; | 172 | : null; |
| 177 | const errorMessage = | 173 | const errorMessage = |
| 178 | validationMessage ?? | 174 | validationMessage ?? |
| 179 | (resolvedPullRequest === null && resolvePullRequestQuery.isError | 175 | (resolvedPullRequest === null && pullRequestResolution.error |
| 180 | ? resolvePullRequestQuery.error instanceof Error | 176 | ? pullRequestResolution.error instanceof Error |
| 181 | ? resolvePullRequestQuery.error.message | 177 | ? pullRequestResolution.error.message |
| 182 | : `Failed to resolve ${terminology.singular}.` | 178 | : `Failed to resolve ${terminology.singular}.` |
| 183 | : preparePullRequestThreadMutation.error instanceof Error | 179 | : preparePullRequestThreadAction.error instanceof Error |
| 184 | ? preparePullRequestThreadMutation.error.message | 180 | ? preparePullRequestThreadAction.error.message |
| 185 | : preparePullRequestThreadMutation.error | 181 | : preparePullRequestThreadAction.error |
| 186 | ? `Failed to prepare ${terminology.singular} thread.` | 182 | ? `Failed to prepare ${terminology.singular} thread.` |
| 187 | : null); | 183 | : null); |
| 188 | 184 | ||
| ... | @@ -190,7 +186,7 @@ export function PullRequestThreadDialog({ | ... | @@ -190,7 +186,7 @@ export function PullRequestThreadDialog({ |
| 190 | <Dialog | 186 | <Dialog |
| 191 | open={open} | 187 | open={open} |
| 192 | onOpenChange={(nextOpen) => { | 188 | onOpenChange={(nextOpen) => { |
| 193 | if (!preparePullRequestThreadMutation.isPending) { | 189 | if (!preparePullRequestThreadAction.isPending) { |
| 194 | onOpenChange(nextOpen); | 190 | onOpenChange(nextOpen); |
| 195 | } | 191 | } |
| 196 | }} | 192 | }} |
| ... | @@ -224,7 +220,7 @@ export function PullRequestThreadDialog({ | ... | @@ -224,7 +220,7 @@ export function PullRequestThreadDialog({ |
| 224 | return; | 220 | return; |
| 225 | } | 221 | } |
| 226 | event.preventDefault(); | 222 | event.preventDefault(); |
| 227 | if (!isResolving && !preparePullRequestThreadMutation.isPending) { | 223 | if (!isResolving && !preparePullRequestThreadAction.isPending) { |
| 228 | void handleConfirm("local"); | 224 | void handleConfirm("local"); |
| 229 | } | 225 | } |
| 230 | }} | 226 | }} |
| ... | @@ -263,7 +259,7 @@ export function PullRequestThreadDialog({ | ... | @@ -263,7 +259,7 @@ export function PullRequestThreadDialog({ |
| 263 | variant="outline" | 259 | variant="outline" |
| 264 | size="sm" | 260 | size="sm" |
| 265 | onClick={() => onOpenChange(false)} | 261 | onClick={() => onOpenChange(false)} |
| 266 | disabled={preparePullRequestThreadMutation.isPending} | 262 | disabled={preparePullRequestThreadAction.isPending} |
| 267 | > | 263 | > |
| 268 | Cancel | 264 | Cancel |
| 269 | </Button> | 265 | </Button> |
| ... | @@ -278,7 +274,7 @@ export function PullRequestThreadDialog({ | ... | @@ -278,7 +274,7 @@ export function PullRequestThreadDialog({ |
| 278 | !cwd || | 274 | !cwd || |
| 279 | !resolvedPullRequest || | 275 | !resolvedPullRequest || |
| 280 | isResolving || | 276 | isResolving || |
| 281 | preparePullRequestThreadMutation.isPending | 277 | preparePullRequestThreadAction.isPending |
| 282 | } | 278 | } |
| 283 | > | 279 | > |
| 284 | {preparingMode === "local" ? "Preparing local..." : "Local"} | 280 | {preparingMode === "local" ? "Preparing local..." : "Local"} |
| ... | @@ -293,7 +289,7 @@ export function PullRequestThreadDialog({ | ... | @@ -293,7 +289,7 @@ export function PullRequestThreadDialog({ |
| 293 | !cwd || | 289 | !cwd || |
| 294 | !resolvedPullRequest || | 290 | !resolvedPullRequest || |
| 295 | isResolving || | 291 | isResolving || |
| 296 | preparePullRequestThreadMutation.isPending | 292 | preparePullRequestThreadAction.isPending |
| 297 | } | 293 | } |
| 298 | > | 294 | > |
| 299 | {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"} | 295 | {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"} |
apps/web/src/components/Sidebar.tsx+12-11| ... | @@ -73,7 +73,8 @@ import { | ... | @@ -73,7 +73,8 @@ import { |
| 73 | selectThreadByRef, | 73 | selectThreadByRef, |
| 74 | useStore, | 74 | useStore, |
| 75 | } from "../store"; | 75 | } from "../store"; |
| 76 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 76 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 77 | import { useThreadRunningTerminalIds } from "../terminalSessionState"; | ||
| 77 | import { useUiStateStore } from "../uiStateStore"; | 78 | import { useUiStateStore } from "../uiStateStore"; |
| 78 | import { | 79 | import { |
| 79 | resolveShortcutCommand, | 80 | resolveShortcutCommand, |
| ... | @@ -85,7 +86,7 @@ import { | ... | @@ -85,7 +86,7 @@ import { |
| 85 | } from "../keybindings"; | 86 | } from "../keybindings"; |
| 86 | import { useModelPickerOpen } from "../modelPickerOpenState"; | 87 | import { useModelPickerOpen } from "../modelPickerOpenState"; |
| 87 | import { useShortcutModifierState } from "../shortcutModifierState"; | 88 | import { useShortcutModifierState } from "../shortcutModifierState"; |
| 88 | import { useGitStatus } from "../lib/gitStatusState"; | 89 | import { useVcsStatus } from "../lib/vcsStatusState"; |
| 89 | import { readLocalApi } from "../localApi"; | 90 | import { readLocalApi } from "../localApi"; |
| 90 | import { useComposerDraftStore } from "../composerDraftStore"; | 91 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 91 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | 92 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| ... | @@ -342,10 +343,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -342,10 +343,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 342 | const threadKey = scopedThreadKey(threadRef); | 343 | const threadKey = scopedThreadKey(threadRef); |
| 343 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); | 344 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); |
| 344 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); | 345 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); |
| 345 | const runningTerminalIds = useTerminalStateStore( | 346 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| 346 | (state) => | 347 | environmentId: thread.environmentId, |
| 347 | selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, | 348 | threadId: thread.id, |
| 348 | ); | 349 | }); |
| 349 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 350 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 350 | const isRemoteThread = | 351 | const isRemoteThread = |
| 351 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; | 352 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; |
| ... | @@ -370,7 +371,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -370,7 +371,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 370 | ), | 371 | ), |
| 371 | ); | 372 | ); |
| 372 | const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; | 373 | const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; |
| 373 | const gitStatus = useGitStatus({ | 374 | const gitStatus = useVcsStatus({ |
| 374 | environmentId: thread.environmentId, | 375 | environmentId: thread.environmentId, |
| 375 | cwd: thread.branch != null ? gitCwd : null, | 376 | cwd: thread.branch != null ? gitCwd : null, |
| 376 | }); | 377 | }); |
| ... | @@ -2926,8 +2927,8 @@ export default function Sidebar() { | ... | @@ -2926,8 +2927,8 @@ export default function Sidebar() { |
| 2926 | () => ({ | 2927 | () => ({ |
| 2927 | terminalFocus: isTerminalFocused(), | 2928 | terminalFocus: isTerminalFocused(), |
| 2928 | terminalOpen: routeThreadRef | 2929 | terminalOpen: routeThreadRef |
| 2929 | ? selectThreadTerminalState( | 2930 | ? selectThreadTerminalUiState( |
| 2930 | useTerminalStateStore.getState().terminalStateByThreadKey, | 2931 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 2931 | routeThreadRef, | 2932 | routeThreadRef, |
| 2932 | ).terminalOpen | 2933 | ).terminalOpen |
| 2933 | : false, | 2934 | : false, |
| ... | @@ -3134,8 +3135,8 @@ export default function Sidebar() { | ... | @@ -3134,8 +3135,8 @@ export default function Sidebar() { |
| 3134 | () => ({ | 3135 | () => ({ |
| 3135 | terminalFocus: false, | 3136 | terminalFocus: false, |
| 3136 | terminalOpen: routeThreadRef | 3137 | terminalOpen: routeThreadRef |
| 3137 | ? selectThreadTerminalState( | 3138 | ? selectThreadTerminalUiState( |
| 3138 | useTerminalStateStore.getState().terminalStateByThreadKey, | 3139 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, |
| 3139 | routeThreadRef, | 3140 | routeThreadRef, |
| 3140 | ).terminalOpen | 3141 | ).terminalOpen |
| 3141 | : false, | 3142 | : false, |
apps/web/src/components/ThreadStatusIndicators.tsx+8-9| ... | @@ -7,9 +7,9 @@ import { | ... | @@ -7,9 +7,9 @@ import { |
| 7 | useSavedEnvironmentRegistryStore, | 7 | useSavedEnvironmentRegistryStore, |
| 8 | useSavedEnvironmentRuntimeStore, | 8 | useSavedEnvironmentRuntimeStore, |
| 9 | } from "../environments/runtime"; | 9 | } from "../environments/runtime"; |
| 10 | import { useGitStatus } from "../lib/gitStatusState"; | 10 | import { useVcsStatus } from "../lib/vcsStatusState"; |
| 11 | import { type AppState, selectProjectByRef, useStore } from "../store"; | 11 | import { type AppState, selectProjectByRef, useStore } from "../store"; |
| 12 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 12 | import { useThreadRunningTerminalIds } from "../terminalSessionState"; |
| 13 | import { useUiStateStore } from "../uiStateStore"; | 13 | import { useUiStateStore } from "../uiStateStore"; |
| 14 | import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; | 14 | import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; |
| 15 | import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; | 15 | import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; |
| ... | @@ -81,7 +81,7 @@ export function resolveThreadPr( | ... | @@ -81,7 +81,7 @@ export function resolveThreadPr( |
| 81 | } | 81 | } |
| 82 | 82 | ||
| 83 | export function terminalStatusFromRunningIds( | 83 | export function terminalStatusFromRunningIds( |
| 84 | runningTerminalIds: string[], | 84 | runningTerminalIds: ReadonlyArray<string>, |
| 85 | ): TerminalStatusIndicator | null { | 85 | ): TerminalStatusIndicator | null { |
| 86 | if (runningTerminalIds.length === 0) { | 86 | if (runningTerminalIds.length === 0) { |
| 87 | return null; | 87 | return null; |
| ... | @@ -150,7 +150,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar | ... | @@ -150,7 +150,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar |
| 150 | ), | 150 | ), |
| 151 | ); | 151 | ); |
| 152 | const gitCwd = thread.worktreePath ?? threadProjectCwd; | 152 | const gitCwd = thread.worktreePath ?? threadProjectCwd; |
| 153 | const gitStatus = useGitStatus({ | 153 | const gitStatus = useVcsStatus({ |
| 154 | environmentId: thread.environmentId, | 154 | environmentId: thread.environmentId, |
| 155 | cwd: thread.branch != null ? gitCwd : null, | 155 | cwd: thread.branch != null ? gitCwd : null, |
| 156 | }); | 156 | }); |
| ... | @@ -195,11 +195,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar | ... | @@ -195,11 +195,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar |
| 195 | * environment indicator, matching the sidebar's trailing indicators. | 195 | * environment indicator, matching the sidebar's trailing indicators. |
| 196 | */ | 196 | */ |
| 197 | export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) { | 197 | export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) { |
| 198 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); | 198 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| 199 | const runningTerminalIds = useTerminalStateStore( | 199 | environmentId: thread.environmentId, |
| 200 | (state) => | 200 | threadId: thread.id, |
| 201 | selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, | 201 | }); |
| 202 | ); | ||
| 203 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 202 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 204 | const isRemoteThread = | 203 | const isRemoteThread = |
| 205 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; | 204 | primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; |
apps/web/src/components/ThreadTerminalDrawer.browser.tsx+121-23| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import "../index.css"; | 1 | import "../index.css"; |
| 2 | 2 | ||
| 3 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 3 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 4 | import { ThreadId } from "@t3tools/contracts"; | 4 | import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; |
| 5 | import { afterEach, describe, expect, it, vi } from "vitest"; | 5 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 6 | import { render } from "vitest-browser-react"; | 6 | import { render } from "vitest-browser-react"; |
| 7 | 7 | ||
| ... | @@ -18,7 +18,17 @@ const { | ... | @@ -18,7 +18,17 @@ const { |
| 18 | terminalDisposeSpy: vi.fn(), | 18 | terminalDisposeSpy: vi.fn(), |
| 19 | fitAddonFitSpy: vi.fn(), | 19 | fitAddonFitSpy: vi.fn(), |
| 20 | fitAddonLoadSpy: vi.fn(), | 20 | fitAddonLoadSpy: vi.fn(), |
| 21 | environmentApiById: new Map<string, { terminal: { open: ReturnType<typeof vi.fn> } }>(), | 21 | environmentApiById: new Map< |
| 22 | string, | ||
| 23 | { | ||
| 24 | terminal: { | ||
| 25 | open: ReturnType<typeof vi.fn>; | ||
| 26 | attach: ReturnType<typeof vi.fn>; | ||
| 27 | write: ReturnType<typeof vi.fn>; | ||
| 28 | resize: ReturnType<typeof vi.fn>; | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | >(), | ||
| 22 | readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), | 32 | readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), |
| 23 | readLocalApiMock: vi.fn< | 33 | readLocalApiMock: vi.fn< |
| 24 | () => | 34 | () => |
| ... | @@ -124,20 +134,33 @@ import { TerminalViewport } from "./ThreadTerminalDrawer"; | ... | @@ -124,20 +134,33 @@ import { TerminalViewport } from "./ThreadTerminalDrawer"; |
| 124 | const THREAD_ID = ThreadId.make("thread-terminal-browser"); | 134 | const THREAD_ID = ThreadId.make("thread-terminal-browser"); |
| 125 | 135 | ||
| 126 | function createEnvironmentApi() { | 136 | function createEnvironmentApi() { |
| 137 | const snapshot = { | ||
| 138 | threadId: THREAD_ID, | ||
| 139 | terminalId: "term-1", | ||
| 140 | cwd: "/repo/project", | ||
| 141 | worktreePath: null, | ||
| 142 | status: "running" as const, | ||
| 143 | pid: 123, | ||
| 144 | history: "", | ||
| 145 | exitCode: null, | ||
| 146 | exitSignal: null, | ||
| 147 | label: "Terminal 1", | ||
| 148 | updatedAt: "2026-04-07T00:00:00.000Z", | ||
| 149 | }; | ||
| 150 | |||
| 127 | return { | 151 | return { |
| 128 | terminal: { | 152 | terminal: { |
| 129 | open: vi.fn(async () => ({ | 153 | open: vi.fn(async () => snapshot), |
| 130 | threadId: THREAD_ID, | 154 | attach: vi.fn( |
| 131 | terminalId: "default", | 155 | ( |
| 132 | cwd: "/repo/project", | 156 | _input: unknown, |
| 133 | worktreePath: null, | 157 | listener: (event: TerminalAttachStreamEvent) => void, |
| 134 | status: "running" as const, | 158 | _options?: unknown, |
| 135 | pid: 123, | 159 | ) => { |
| 136 | history: "", | 160 | listener({ type: "snapshot", snapshot }); |
| 137 | exitCode: null, | 161 | return vi.fn(); |
| 138 | exitSignal: null, | 162 | }, |
| 139 | updatedAt: "2026-04-07T00:00:00.000Z", | 163 | ), |
| 140 | })), | ||
| 141 | write: vi.fn(async () => undefined), | 164 | write: vi.fn(async () => undefined), |
| 142 | resize: vi.fn(async () => undefined), | 165 | resize: vi.fn(async () => undefined), |
| 143 | }, | 166 | }, |
| ... | @@ -148,6 +171,7 @@ async function mountTerminalViewport(props: { | ... | @@ -148,6 +171,7 @@ async function mountTerminalViewport(props: { |
| 148 | threadRef: ReturnType<typeof scopeThreadRef>; | 171 | threadRef: ReturnType<typeof scopeThreadRef>; |
| 149 | drawerBackgroundColor?: string; | 172 | drawerBackgroundColor?: string; |
| 150 | drawerTextColor?: string; | 173 | drawerTextColor?: string; |
| 174 | runtimeEnv?: Record<string, string>; | ||
| 151 | }) { | 175 | }) { |
| 152 | const drawer = document.createElement("div"); | 176 | const drawer = document.createElement("div"); |
| 153 | drawer.className = "thread-terminal-drawer"; | 177 | drawer.className = "thread-terminal-drawer"; |
| ... | @@ -168,9 +192,10 @@ async function mountTerminalViewport(props: { | ... | @@ -168,9 +192,10 @@ async function mountTerminalViewport(props: { |
| 168 | <TerminalViewport | 192 | <TerminalViewport |
| 169 | threadRef={props.threadRef} | 193 | threadRef={props.threadRef} |
| 170 | threadId={THREAD_ID} | 194 | threadId={THREAD_ID} |
| 171 | terminalId="default" | 195 | terminalId="term-1" |
| 172 | terminalLabel="Terminal" | 196 | terminalLabel="Terminal" |
| 173 | cwd="/repo/project" | 197 | cwd="/repo/project" |
| 198 | {...(props.runtimeEnv ? { runtimeEnv: props.runtimeEnv } : {})} | ||
| 174 | onSessionExited={() => undefined} | 199 | onSessionExited={() => undefined} |
| 175 | onAddTerminalContext={() => undefined} | 200 | onAddTerminalContext={() => undefined} |
| 176 | focusRequestId={0} | 201 | focusRequestId={0} |
| ... | @@ -183,14 +208,18 @@ async function mountTerminalViewport(props: { | ... | @@ -183,14 +208,18 @@ async function mountTerminalViewport(props: { |
| 183 | ); | 208 | ); |
| 184 | 209 | ||
| 185 | return { | 210 | return { |
| 186 | rerender: async (nextProps: { threadRef: ReturnType<typeof scopeThreadRef> }) => { | 211 | rerender: async (nextProps: { |
| 212 | threadRef: ReturnType<typeof scopeThreadRef>; | ||
| 213 | runtimeEnv?: Record<string, string>; | ||
| 214 | }) => { | ||
| 187 | await screen.rerender( | 215 | await screen.rerender( |
| 188 | <TerminalViewport | 216 | <TerminalViewport |
| 189 | threadRef={nextProps.threadRef} | 217 | threadRef={nextProps.threadRef} |
| 190 | threadId={THREAD_ID} | 218 | threadId={THREAD_ID} |
| 191 | terminalId="default" | 219 | terminalId="term-1" |
| 192 | terminalLabel="Terminal" | 220 | terminalLabel="Terminal" |
| 193 | cwd="/repo/project" | 221 | cwd="/repo/project" |
| 222 | {...(nextProps.runtimeEnv ? { runtimeEnv: nextProps.runtimeEnv } : {})} | ||
| 194 | onSessionExited={() => undefined} | 223 | onSessionExited={() => undefined} |
| 195 | onAddTerminalContext={() => undefined} | 224 | onAddTerminalContext={() => undefined} |
| 196 | focusRequestId={0} | 225 | focusRequestId={0} |
| ... | @@ -236,7 +265,48 @@ describe("TerminalViewport", () => { | ... | @@ -236,7 +265,48 @@ describe("TerminalViewport", () => { |
| 236 | } | 265 | } |
| 237 | }); | 266 | }); |
| 238 | 267 | ||
| 239 | it("reopens the terminal when the scoped thread reference changes", async () => { | 268 | it("renders and attaches the terminal without the desktop local API", async () => { |
| 269 | const environment = createEnvironmentApi(); | ||
| 270 | environmentApiById.set("environment-a", environment); | ||
| 271 | readLocalApiMock.mockReturnValueOnce(undefined); | ||
| 272 | |||
| 273 | const mounted = await mountTerminalViewport({ | ||
| 274 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 275 | }); | ||
| 276 | |||
| 277 | try { | ||
| 278 | await vi.waitFor(() => { | ||
| 279 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 280 | }); | ||
| 281 | expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); | ||
| 282 | } finally { | ||
| 283 | await mounted.cleanup(); | ||
| 284 | } | ||
| 285 | }); | ||
| 286 | |||
| 287 | it("keeps the terminal mounted when xterm fit runs before dimensions are ready", async () => { | ||
| 288 | const environment = createEnvironmentApi(); | ||
| 289 | environmentApiById.set("environment-a", environment); | ||
| 290 | fitAddonFitSpy.mockImplementationOnce(() => { | ||
| 291 | throw new TypeError("Cannot read properties of undefined (reading 'dimensions')"); | ||
| 292 | }); | ||
| 293 | |||
| 294 | const mounted = await mountTerminalViewport({ | ||
| 295 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 296 | }); | ||
| 297 | |||
| 298 | try { | ||
| 299 | await vi.waitFor(() => { | ||
| 300 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 301 | }); | ||
| 302 | expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); | ||
| 303 | expect(fitAddonFitSpy).toHaveBeenCalled(); | ||
| 304 | } finally { | ||
| 305 | await mounted.cleanup(); | ||
| 306 | } | ||
| 307 | }); | ||
| 308 | |||
| 309 | it("reattaches the terminal when the scoped thread reference changes", async () => { | ||
| 240 | const environmentA = createEnvironmentApi(); | 310 | const environmentA = createEnvironmentApi(); |
| 241 | const environmentB = createEnvironmentApi(); | 311 | const environmentB = createEnvironmentApi(); |
| 242 | environmentApiById.set("environment-a", environmentA); | 312 | environmentApiById.set("environment-a", environmentA); |
| ... | @@ -248,7 +318,7 @@ describe("TerminalViewport", () => { | ... | @@ -248,7 +318,7 @@ describe("TerminalViewport", () => { |
| 248 | 318 | ||
| 249 | try { | 319 | try { |
| 250 | await vi.waitFor(() => { | 320 | await vi.waitFor(() => { |
| 251 | expect(environmentA.terminal.open).toHaveBeenCalledTimes(1); | 321 | expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1); |
| 252 | }); | 322 | }); |
| 253 | 323 | ||
| 254 | await mounted.rerender({ | 324 | await mounted.rerender({ |
| ... | @@ -256,7 +326,7 @@ describe("TerminalViewport", () => { | ... | @@ -256,7 +326,7 @@ describe("TerminalViewport", () => { |
| 256 | }); | 326 | }); |
| 257 | 327 | ||
| 258 | await vi.waitFor(() => { | 328 | await vi.waitFor(() => { |
| 259 | expect(environmentB.terminal.open).toHaveBeenCalledTimes(1); | 329 | expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1); |
| 260 | }); | 330 | }); |
| 261 | expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); | 331 | expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); |
| 262 | } finally { | 332 | } finally { |
| ... | @@ -264,25 +334,53 @@ describe("TerminalViewport", () => { | ... | @@ -264,25 +334,53 @@ describe("TerminalViewport", () => { |
| 264 | } | 334 | } |
| 265 | }); | 335 | }); |
| 266 | 336 | ||
| 267 | it("does not reopen the terminal when the scoped thread reference values stay the same", async () => { | 337 | it("does not reattach the terminal when the scoped thread reference values stay the same", async () => { |
| 338 | const environment = createEnvironmentApi(); | ||
| 339 | environmentApiById.set("environment-a", environment); | ||
| 340 | |||
| 341 | const mounted = await mountTerminalViewport({ | ||
| 342 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 343 | }); | ||
| 344 | |||
| 345 | try { | ||
| 346 | await vi.waitFor(() => { | ||
| 347 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 348 | }); | ||
| 349 | |||
| 350 | await mounted.rerender({ | ||
| 351 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | ||
| 352 | }); | ||
| 353 | |||
| 354 | await vi.waitFor(() => { | ||
| 355 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); | ||
| 356 | }); | ||
| 357 | expect(terminalDisposeSpy).not.toHaveBeenCalled(); | ||
| 358 | } finally { | ||
| 359 | await mounted.cleanup(); | ||
| 360 | } | ||
| 361 | }); | ||
| 362 | |||
| 363 | it("does not reattach when runtime env contents are unchanged but object identity changes", async () => { | ||
| 268 | const environment = createEnvironmentApi(); | 364 | const environment = createEnvironmentApi(); |
| 269 | environmentApiById.set("environment-a", environment); | 365 | environmentApiById.set("environment-a", environment); |
| 270 | 366 | ||
| 271 | const mounted = await mountTerminalViewport({ | 367 | const mounted = await mountTerminalViewport({ |
| 272 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | 368 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), |
| 369 | runtimeEnv: { PATH: "/usr/bin", T3: "1" }, | ||
| 273 | }); | 370 | }); |
| 274 | 371 | ||
| 275 | try { | 372 | try { |
| 276 | await vi.waitFor(() => { | 373 | await vi.waitFor(() => { |
| 277 | expect(environment.terminal.open).toHaveBeenCalledTimes(1); | 374 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); |
| 278 | }); | 375 | }); |
| 279 | 376 | ||
| 280 | await mounted.rerender({ | 377 | await mounted.rerender({ |
| 281 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), | 378 | threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), |
| 379 | runtimeEnv: { T3: "1", PATH: "/usr/bin" }, | ||
| 282 | }); | 380 | }); |
| 283 | 381 | ||
| 284 | await vi.waitFor(() => { | 382 | await vi.waitFor(() => { |
| 285 | expect(environment.terminal.open).toHaveBeenCalledTimes(1); | 383 | expect(environment.terminal.attach).toHaveBeenCalledTimes(1); |
| 286 | }); | 384 | }); |
| 287 | expect(terminalDisposeSpy).not.toHaveBeenCalled(); | 385 | expect(terminalDisposeSpy).not.toHaveBeenCalled(); |
| 288 | } finally { | 386 | } finally { |
apps/web/src/components/ThreadTerminalDrawer.test.ts-62| ... | @@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest"; | ... | @@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | resolveTerminalSelectionActionPosition, | 4 | resolveTerminalSelectionActionPosition, |
| 5 | selectPendingTerminalEventEntries, | ||
| 6 | selectTerminalEventEntriesAfterSnapshot, | ||
| 7 | shouldHandleTerminalSelectionMouseUp, | 5 | shouldHandleTerminalSelectionMouseUp, |
| 8 | terminalSelectionActionDelayForClickCount, | 6 | terminalSelectionActionDelayForClickCount, |
| 9 | } from "./ThreadTerminalDrawer"; | 7 | } from "./ThreadTerminalDrawer"; |
| ... | @@ -74,64 +72,4 @@ describe("resolveTerminalSelectionActionPosition", () => { | ... | @@ -74,64 +72,4 @@ describe("resolveTerminalSelectionActionPosition", () => { |
| 74 | expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); | 72 | expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); |
| 75 | expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); | 73 | expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); |
| 76 | }); | 74 | }); |
| 77 | |||
| 78 | it("replays only terminal events newer than the open snapshot", () => { | ||
| 79 | expect( | ||
| 80 | selectTerminalEventEntriesAfterSnapshot( | ||
| 81 | [ | ||
| 82 | { | ||
| 83 | id: 1, | ||
| 84 | event: { | ||
| 85 | threadId: "thread-1", | ||
| 86 | terminalId: "default", | ||
| 87 | createdAt: "2026-04-02T20:00:00.000Z", | ||
| 88 | type: "output", | ||
| 89 | data: "before", | ||
| 90 | }, | ||
| 91 | }, | ||
| 92 | { | ||
| 93 | id: 2, | ||
| 94 | event: { | ||
| 95 | threadId: "thread-1", | ||
| 96 | terminalId: "default", | ||
| 97 | createdAt: "2026-04-02T20:00:01.000Z", | ||
| 98 | type: "output", | ||
| 99 | data: "after", | ||
| 100 | }, | ||
| 101 | }, | ||
| 102 | ], | ||
| 103 | "2026-04-02T20:00:00.500Z", | ||
| 104 | ).map((entry) => entry.id), | ||
| 105 | ).toEqual([2]); | ||
| 106 | }); | ||
| 107 | |||
| 108 | it("applies only terminal events that have not already been consumed", () => { | ||
| 109 | expect( | ||
| 110 | selectPendingTerminalEventEntries( | ||
| 111 | [ | ||
| 112 | { | ||
| 113 | id: 1, | ||
| 114 | event: { | ||
| 115 | threadId: "thread-1", | ||
| 116 | terminalId: "default", | ||
| 117 | createdAt: "2026-04-02T20:00:00.000Z", | ||
| 118 | type: "output", | ||
| 119 | data: "one", | ||
| 120 | }, | ||
| 121 | }, | ||
| 122 | { | ||
| 123 | id: 2, | ||
| 124 | event: { | ||
| 125 | threadId: "thread-1", | ||
| 126 | terminalId: "default", | ||
| 127 | createdAt: "2026-04-02T20:00:01.000Z", | ||
| 128 | type: "output", | ||
| 129 | data: "two", | ||
| 130 | }, | ||
| 131 | }, | ||
| 132 | ], | ||
| 133 | 1, | ||
| 134 | ).map((entry) => entry.id), | ||
| 135 | ).toEqual([2]); | ||
| 136 | }); | ||
| 137 | }); | 75 | }); |
apps/web/src/components/ThreadTerminalDrawer.tsx+232-182| ... | @@ -3,10 +3,11 @@ import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "luci | ... | @@ -3,10 +3,11 @@ import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "luci |
| 3 | import { | 3 | import { |
| 4 | type ResolvedKeybindingsConfig, | 4 | type ResolvedKeybindingsConfig, |
| 5 | type ScopedThreadRef, | 5 | type ScopedThreadRef, |
| 6 | type TerminalEvent, | 6 | type TerminalAttachStreamEvent, |
| 7 | type TerminalSessionSnapshot, | 7 | type TerminalSessionSnapshot, |
| 8 | type ThreadId, | 8 | type ThreadId, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | ||
| 10 | import { Terminal, type ITheme } from "@xterm/xterm"; | 11 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 11 | import { | 12 | import { |
| 12 | type PointerEvent as ReactPointerEvent, | 13 | type PointerEvent as ReactPointerEvent, |
| ... | @@ -41,13 +42,12 @@ import { | ... | @@ -41,13 +42,12 @@ import { |
| 41 | } from "../keybindings"; | 42 | } from "../keybindings"; |
| 42 | import { | 43 | import { |
| 43 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 44 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 44 | DEFAULT_THREAD_TERMINAL_ID, | ||
| 45 | MAX_TERMINALS_PER_GROUP, | 45 | MAX_TERMINALS_PER_GROUP, |
| 46 | type ThreadTerminalGroup, | 46 | type ThreadTerminalGroup, |
| 47 | } from "../types"; | 47 | } from "../types"; |
| 48 | import { readEnvironmentApi } from "~/environmentApi"; | 48 | import { readEnvironmentApi } from "~/environmentApi"; |
| 49 | import { readLocalApi } from "~/localApi"; | 49 | import { readLocalApi } from "~/localApi"; |
| 50 | import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; | 50 | import { attachTerminalSession } from "../terminalSessionState"; |
| 51 | 51 | ||
| 52 | const MIN_DRAWER_HEIGHT = 180; | 52 | const MIN_DRAWER_HEIGHT = 180; |
| 53 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; | 53 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | @@ -75,18 +75,22 @@ function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnap | ... | @@ -75,18 +75,22 @@ function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnap |
| 75 | } | 75 | } |
| 76 | } | 76 | } |
| 77 | 77 | ||
| 78 | export function selectTerminalEventEntriesAfterSnapshot( | 78 | function fitTerminalSafely(fitAddon: FitAddon): boolean { |
| 79 | entries: ReadonlyArray<{ id: number; event: TerminalEvent }>, | 79 | try { |
| 80 | snapshotUpdatedAt: string, | 80 | fitAddon.fit(); |
| 81 | ): ReadonlyArray<{ id: number; event: TerminalEvent }> { | 81 | return true; |
| 82 | return entries.filter((entry) => entry.event.createdAt > snapshotUpdatedAt); | 82 | } catch { |
| 83 | return false; | ||
| 84 | } | ||
| 83 | } | 85 | } |
| 84 | 86 | ||
| 85 | export function selectPendingTerminalEventEntries( | 87 | function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string { |
| 86 | entries: ReadonlyArray<{ id: number; event: TerminalEvent }>, | 88 | if (!runtimeEnv) return ""; |
| 87 | lastAppliedTerminalEventId: number, | 89 | return JSON.stringify( |
| 88 | ): ReadonlyArray<{ id: number; event: TerminalEvent }> { | 90 | Object.entries(runtimeEnv) |
| 89 | return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); | 91 | .filter(([key, value]) => key.length > 0 && typeof value === "string") |
| 92 | .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)), | ||
| 93 | ); | ||
| 90 | } | 94 | } |
| 91 | 95 | ||
| 92 | function normalizeComputedColor(value: string | null | undefined, fallback: string): string { | 96 | function normalizeComputedColor(value: string | null | undefined, fallback: string): string { |
| ... | @@ -264,6 +268,12 @@ interface TerminalViewportProps { | ... | @@ -264,6 +268,12 @@ interface TerminalViewportProps { |
| 264 | keybindings: ResolvedKeybindingsConfig; | 268 | keybindings: ResolvedKeybindingsConfig; |
| 265 | } | 269 | } |
| 266 | 270 | ||
| 271 | interface TerminalLaunchLocation { | ||
| 272 | readonly cwd: string; | ||
| 273 | readonly worktreePath?: string | null; | ||
| 274 | readonly runtimeEnv?: Record<string, string>; | ||
| 275 | } | ||
| 276 | |||
| 267 | export function TerminalViewport({ | 277 | export function TerminalViewport({ |
| 268 | threadRef, | 278 | threadRef, |
| 269 | threadId, | 279 | threadId, |
| ... | @@ -291,8 +301,7 @@ export function TerminalViewport({ | ... | @@ -291,8 +301,7 @@ export function TerminalViewport({ |
| 291 | const selectionActionOpenRef = useRef(false); | 301 | const selectionActionOpenRef = useRef(false); |
| 292 | const selectionActionTimerRef = useRef<number | null>(null); | 302 | const selectionActionTimerRef = useRef<number | null>(null); |
| 293 | const keybindingsRef = useRef(keybindings); | 303 | const keybindingsRef = useRef(keybindings); |
| 294 | const lastAppliedTerminalEventIdRef = useRef(0); | 304 | const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); |
| 295 | const terminalHydratedRef = useRef(false); | ||
| 296 | const handleSessionExited = useEffectEvent(() => { | 305 | const handleSessionExited = useEffectEvent(() => { |
| 297 | onSessionExited(); | 306 | onSessionExited(); |
| 298 | }); | 307 | }); |
| ... | @@ -312,7 +321,7 @@ export function TerminalViewport({ | ... | @@ -312,7 +321,7 @@ export function TerminalViewport({ |
| 312 | let disposed = false; | 321 | let disposed = false; |
| 313 | const api = readEnvironmentApi(environmentId); | 322 | const api = readEnvironmentApi(environmentId); |
| 314 | const localApi = readLocalApi(); | 323 | const localApi = readLocalApi(); |
| 315 | if (!api || !localApi) return; | 324 | if (!api) return; |
| 316 | 325 | ||
| 317 | const fitAddon = new FitAddon(); | 326 | const fitAddon = new FitAddon(); |
| 318 | const terminal = new Terminal({ | 327 | const terminal = new Terminal({ |
| ... | @@ -325,7 +334,7 @@ export function TerminalViewport({ | ... | @@ -325,7 +334,7 @@ export function TerminalViewport({ |
| 325 | }); | 334 | }); |
| 326 | terminal.loadAddon(fitAddon); | 335 | terminal.loadAddon(fitAddon); |
| 327 | terminal.open(mount); | 336 | terminal.open(mount); |
| 328 | fitAddon.fit(); | 337 | fitTerminalSafely(fitAddon); |
| 329 | 338 | ||
| 330 | terminalRef.current = terminal; | 339 | terminalRef.current = terminal; |
| 331 | fitAddonRef.current = fitAddon; | 340 | fitAddonRef.current = fitAddon; |
| ... | @@ -379,6 +388,10 @@ export function TerminalViewport({ | ... | @@ -379,6 +388,10 @@ export function TerminalViewport({ |
| 379 | }; | 388 | }; |
| 380 | 389 | ||
| 381 | const showSelectionAction = async () => { | 390 | const showSelectionAction = async () => { |
| 391 | if (!localApi) { | ||
| 392 | clearSelectionAction(); | ||
| 393 | return; | ||
| 394 | } | ||
| 382 | if (selectionActionOpenRef.current) { | 395 | if (selectionActionOpenRef.current) { |
| 383 | return; | 396 | return; |
| 384 | } | 397 | } |
| ... | @@ -489,6 +502,10 @@ export function TerminalViewport({ | ... | @@ -489,6 +502,10 @@ export function TerminalViewport({ |
| 489 | 502 | ||
| 490 | const latestTerminal = terminalRef.current; | 503 | const latestTerminal = terminalRef.current; |
| 491 | if (!latestTerminal) return; | 504 | if (!latestTerminal) return; |
| 505 | if (!localApi) { | ||
| 506 | writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); | ||
| 507 | return; | ||
| 508 | } | ||
| 492 | 509 | ||
| 493 | if (match.kind === "url") { | 510 | if (match.kind === "url") { |
| 494 | void localApi.shell.openExternal(match.text).catch((error: unknown) => { | 511 | void localApi.shell.openExternal(match.text).catch((error: unknown) => { |
| ... | @@ -567,7 +584,7 @@ export function TerminalViewport({ | ... | @@ -567,7 +584,7 @@ export function TerminalViewport({ |
| 567 | attributeFilter: ["class", "style"], | 584 | attributeFilter: ["class", "style"], |
| 568 | }); | 585 | }); |
| 569 | 586 | ||
| 570 | const applyTerminalEvent = (event: TerminalEvent) => { | 587 | const applyAttachEvent = (event: TerminalAttachStreamEvent) => { |
| 571 | const activeTerminal = terminalRef.current; | 588 | const activeTerminal = terminalRef.current; |
| 572 | if (!activeTerminal) { | 589 | if (!activeTerminal) { |
| 573 | return; | 590 | return; |
| ... | @@ -577,13 +594,20 @@ export function TerminalViewport({ | ... | @@ -577,13 +594,20 @@ export function TerminalViewport({ |
| 577 | return; | 594 | return; |
| 578 | } | 595 | } |
| 579 | 596 | ||
| 597 | if (event.type === "snapshot") { | ||
| 598 | hasHandledExitRef.current = false; | ||
| 599 | clearSelectionAction(); | ||
| 600 | writeTerminalSnapshot(activeTerminal, event.snapshot); | ||
| 601 | return; | ||
| 602 | } | ||
| 603 | |||
| 580 | if (event.type === "output") { | 604 | if (event.type === "output") { |
| 581 | activeTerminal.write(event.data); | 605 | activeTerminal.write(event.data); |
| 582 | clearSelectionAction(); | 606 | clearSelectionAction(); |
| 583 | return; | 607 | return; |
| 584 | } | 608 | } |
| 585 | 609 | ||
| 586 | if (event.type === "started" || event.type === "restarted") { | 610 | if (event.type === "restarted") { |
| 587 | hasHandledExitRef.current = false; | 611 | hasHandledExitRef.current = false; |
| 588 | clearSelectionAction(); | 612 | clearSelectionAction(); |
| 589 | writeTerminalSnapshot(activeTerminal, event.snapshot); | 613 | writeTerminalSnapshot(activeTerminal, event.snapshot); |
| ... | @@ -602,16 +626,21 @@ export function TerminalViewport({ | ... | @@ -602,16 +626,21 @@ export function TerminalViewport({ |
| 602 | return; | 626 | return; |
| 603 | } | 627 | } |
| 604 | 628 | ||
| 605 | const details = [ | 629 | if (event.type === "closed") { |
| 606 | typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, | 630 | writeSystemMessage(activeTerminal, "Terminal closed"); |
| 607 | typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, | 631 | } else { |
| 608 | ] | 632 | const details = [ |
| 609 | .filter((value): value is string => value !== null) | 633 | typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, |
| 610 | .join(", "); | 634 | typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, |
| 611 | writeSystemMessage( | 635 | ] |
| 612 | activeTerminal, | 636 | .filter((value): value is string => value !== null) |
| 613 | details.length > 0 ? `Process exited (${details})` : "Process exited", | 637 | .join(", "); |
| 614 | ); | 638 | writeSystemMessage( |
| 639 | activeTerminal, | ||
| 640 | details.length > 0 ? `Process exited (${details})` : "Process exited", | ||
| 641 | ); | ||
| 642 | } | ||
| 643 | |||
| 615 | if (hasHandledExitRef.current) { | 644 | if (hasHandledExitRef.current) { |
| 616 | return; | 645 | return; |
| 617 | } | 646 | } |
| ... | @@ -623,54 +652,16 @@ export function TerminalViewport({ | ... | @@ -623,54 +652,16 @@ export function TerminalViewport({ |
| 623 | handleSessionExited(); | 652 | handleSessionExited(); |
| 624 | }, 0); | 653 | }, 0); |
| 625 | }; | 654 | }; |
| 626 | const applyPendingTerminalEvents = ( | 655 | let unsubscribeAttach: (() => void) | null = null; |
| 627 | terminalEventEntries: ReadonlyArray<{ id: number; event: TerminalEvent }>, | 656 | const attachTerminal = () => { |
| 628 | ) => { | 657 | const activeTerminal = terminalRef.current; |
| 629 | const pendingEntries = selectPendingTerminalEventEntries( | 658 | const activeFitAddon = fitAddonRef.current; |
| 630 | terminalEventEntries, | 659 | if (!activeTerminal || !activeFitAddon) return; |
| 631 | lastAppliedTerminalEventIdRef.current, | 660 | fitTerminalSafely(activeFitAddon); |
| 632 | ); | 661 | unsubscribeAttach = attachTerminalSession({ |
| 633 | if (pendingEntries.length === 0) { | 662 | environmentId, |
| 634 | return; | 663 | client: api, |
| 635 | } | 664 | terminal: { |
| 636 | for (const entry of pendingEntries) { | ||
| 637 | applyTerminalEvent(entry.event); | ||
| 638 | } | ||
| 639 | lastAppliedTerminalEventIdRef.current = | ||
| 640 | pendingEntries.at(-1)?.id ?? lastAppliedTerminalEventIdRef.current; | ||
| 641 | }; | ||
| 642 | |||
| 643 | const unsubscribeTerminalEvents = useTerminalStateStore.subscribe((state, previousState) => { | ||
| 644 | if (!terminalHydratedRef.current) { | ||
| 645 | return; | ||
| 646 | } | ||
| 647 | |||
| 648 | const previousLastEntryId = | ||
| 649 | selectTerminalEventEntries( | ||
| 650 | previousState.terminalEventEntriesByKey, | ||
| 651 | threadRef, | ||
| 652 | terminalId, | ||
| 653 | ).at(-1)?.id ?? 0; | ||
| 654 | const nextEntries = selectTerminalEventEntries( | ||
| 655 | state.terminalEventEntriesByKey, | ||
| 656 | threadRef, | ||
| 657 | terminalId, | ||
| 658 | ); | ||
| 659 | const nextLastEntryId = nextEntries.at(-1)?.id ?? 0; | ||
| 660 | if (nextLastEntryId === previousLastEntryId) { | ||
| 661 | return; | ||
| 662 | } | ||
| 663 | |||
| 664 | applyPendingTerminalEvents(nextEntries); | ||
| 665 | }); | ||
| 666 | |||
| 667 | const openTerminal = async () => { | ||
| 668 | try { | ||
| 669 | const activeTerminal = terminalRef.current; | ||
| 670 | const activeFitAddon = fitAddonRef.current; | ||
| 671 | if (!activeTerminal || !activeFitAddon) return; | ||
| 672 | activeFitAddon.fit(); | ||
| 673 | const snapshot = await api.terminal.open({ | ||
| 674 | threadId, | 665 | threadId, |
| 675 | terminalId, | 666 | terminalId, |
| 676 | cwd, | 667 | cwd, |
| ... | @@ -678,35 +669,20 @@ export function TerminalViewport({ | ... | @@ -678,35 +669,20 @@ export function TerminalViewport({ |
| 678 | cols: activeTerminal.cols, | 669 | cols: activeTerminal.cols, |
| 679 | rows: activeTerminal.rows, | 670 | rows: activeTerminal.rows, |
| 680 | ...(runtimeEnv ? { env: runtimeEnv } : {}), | 671 | ...(runtimeEnv ? { env: runtimeEnv } : {}), |
| 681 | }); | 672 | }, |
| 682 | if (disposed) return; | 673 | onEvent: (event) => { |
| 683 | writeTerminalSnapshot(activeTerminal, snapshot); | 674 | if (disposed) return; |
| 684 | const bufferedEntries = selectTerminalEventEntries( | 675 | applyAttachEvent(event); |
| 685 | useTerminalStateStore.getState().terminalEventEntriesByKey, | 676 | }, |
| 686 | threadRef, | 677 | onSnapshot: () => { |
| 687 | terminalId, | 678 | if (disposed) return; |
| 688 | ); | 679 | if (autoFocus) { |
| 689 | const replayEntries = selectTerminalEventEntriesAfterSnapshot( | 680 | window.requestAnimationFrame(() => { |
| 690 | bufferedEntries, | 681 | activeTerminal.focus(); |
| 691 | snapshot.updatedAt, | 682 | }); |
| 692 | ); | 683 | } |
| 693 | for (const entry of replayEntries) { | 684 | }, |
| 694 | applyTerminalEvent(entry.event); | 685 | }); |
| 695 | } | ||
| 696 | lastAppliedTerminalEventIdRef.current = bufferedEntries.at(-1)?.id ?? 0; | ||
| 697 | terminalHydratedRef.current = true; | ||
| 698 | if (autoFocus) { | ||
| 699 | window.requestAnimationFrame(() => { | ||
| 700 | activeTerminal.focus(); | ||
| 701 | }); | ||
| 702 | } | ||
| 703 | } catch (err) { | ||
| 704 | if (disposed) return; | ||
| 705 | writeSystemMessage( | ||
| 706 | terminal, | ||
| 707 | err instanceof Error ? err.message : "Failed to open terminal", | ||
| 708 | ); | ||
| 709 | } | ||
| 710 | }; | 686 | }; |
| 711 | 687 | ||
| 712 | const fitTimer = window.setTimeout(() => { | 688 | const fitTimer = window.setTimeout(() => { |
| ... | @@ -715,7 +691,7 @@ export function TerminalViewport({ | ... | @@ -715,7 +691,7 @@ export function TerminalViewport({ |
| 715 | if (!activeTerminal || !activeFitAddon) return; | 691 | if (!activeTerminal || !activeFitAddon) return; |
| 716 | const wasAtBottom = | 692 | const wasAtBottom = |
| 717 | activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; | 693 | activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; |
| 718 | activeFitAddon.fit(); | 694 | fitTerminalSafely(activeFitAddon); |
| 719 | if (wasAtBottom) { | 695 | if (wasAtBottom) { |
| 720 | activeTerminal.scrollToBottom(); | 696 | activeTerminal.scrollToBottom(); |
| 721 | } | 697 | } |
| ... | @@ -728,13 +704,12 @@ export function TerminalViewport({ | ... | @@ -728,13 +704,12 @@ export function TerminalViewport({ |
| 728 | }) | 704 | }) |
| 729 | .catch(() => undefined); | 705 | .catch(() => undefined); |
| 730 | }, 30); | 706 | }, 30); |
| 731 | void openTerminal(); | 707 | attachTerminal(); |
| 732 | 708 | ||
| 733 | return () => { | 709 | return () => { |
| 734 | disposed = true; | 710 | disposed = true; |
| 735 | terminalHydratedRef.current = false; | 711 | unsubscribeAttach?.(); |
| 736 | lastAppliedTerminalEventIdRef.current = 0; | 712 | unsubscribeAttach = null; |
| 737 | unsubscribeTerminalEvents(); | ||
| 738 | window.clearTimeout(fitTimer); | 713 | window.clearTimeout(fitTimer); |
| 739 | inputDisposable.dispose(); | 714 | inputDisposable.dispose(); |
| 740 | selectionDisposable.dispose(); | 715 | selectionDisposable.dispose(); |
| ... | @@ -752,7 +727,7 @@ export function TerminalViewport({ | ... | @@ -752,7 +727,7 @@ export function TerminalViewport({ |
| 752 | // autoFocus is intentionally omitted; | 727 | // autoFocus is intentionally omitted; |
| 753 | // it is only read at mount time and must not trigger terminal teardown/recreation. | 728 | // it is only read at mount time and must not trigger terminal teardown/recreation. |
| 754 | // eslint-disable-next-line react-hooks/exhaustive-deps | 729 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 755 | }, [cwd, environmentId, runtimeEnv, terminalId, threadId]); | 730 | }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); |
| 756 | 731 | ||
| 757 | useEffect(() => { | 732 | useEffect(() => { |
| 758 | if (!autoFocus) return; | 733 | if (!autoFocus) return; |
| ... | @@ -773,7 +748,7 @@ export function TerminalViewport({ | ... | @@ -773,7 +748,7 @@ export function TerminalViewport({ |
| 773 | if (!api || !terminal || !fitAddon) return; | 748 | if (!api || !terminal || !fitAddon) return; |
| 774 | const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; | 749 | const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; |
| 775 | const frame = window.requestAnimationFrame(() => { | 750 | const frame = window.requestAnimationFrame(() => { |
| 776 | fitAddon.fit(); | 751 | fitTerminalSafely(fitAddon); |
| 777 | if (wasAtBottom) { | 752 | if (wasAtBottom) { |
| 778 | terminal.scrollToBottom(); | 753 | terminal.scrollToBottom(); |
| 779 | } | 754 | } |
| ... | @@ -821,6 +796,10 @@ interface ThreadTerminalDrawerProps { | ... | @@ -821,6 +796,10 @@ interface ThreadTerminalDrawerProps { |
| 821 | onHeightChange: (height: number) => void; | 796 | onHeightChange: (height: number) => void; |
| 822 | onAddTerminalContext: (selection: TerminalContextSelection) => void; | 797 | onAddTerminalContext: (selection: TerminalContextSelection) => void; |
| 823 | keybindings: ResolvedKeybindingsConfig; | 798 | keybindings: ResolvedKeybindingsConfig; |
| 799 | /** Prefer server-provided tab titles when present (e.g. active subprocess name). */ | ||
| 800 | terminalLabelsById?: ReadonlyMap<string, string>; | ||
| 801 | /** Prefer per-session launch locations when the server already knows a terminal. */ | ||
| 802 | terminalLaunchLocationsById?: ReadonlyMap<string, TerminalLaunchLocation>; | ||
| 824 | } | 803 | } |
| 825 | 804 | ||
| 826 | interface TerminalActionButtonProps { | 805 | interface TerminalActionButtonProps { |
| ... | @@ -875,6 +854,8 @@ export default function ThreadTerminalDrawer({ | ... | @@ -875,6 +854,8 @@ export default function ThreadTerminalDrawer({ |
| 875 | onHeightChange, | 854 | onHeightChange, |
| 876 | onAddTerminalContext, | 855 | onAddTerminalContext, |
| 877 | keybindings, | 856 | keybindings, |
| 857 | terminalLabelsById, | ||
| 858 | terminalLaunchLocationsById, | ||
| 878 | }: ThreadTerminalDrawerProps) { | 859 | }: ThreadTerminalDrawerProps) { |
| 879 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); | 860 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); |
| 880 | const [resizeEpoch, setResizeEpoch] = useState(0); | 861 | const [resizeEpoch, setResizeEpoch] = useState(0); |
| ... | @@ -889,15 +870,28 @@ export default function ThreadTerminalDrawer({ | ... | @@ -889,15 +870,28 @@ export default function ThreadTerminalDrawer({ |
| 889 | const didResizeDuringDragRef = useRef(false); | 870 | const didResizeDuringDragRef = useRef(false); |
| 890 | 871 | ||
| 891 | const normalizedTerminalIds = useMemo(() => { | 872 | const normalizedTerminalIds = useMemo(() => { |
| 892 | const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | 873 | const normalizedIds: string[] = []; |
| 893 | return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID]; | 874 | const seen = new Set<string>(); |
| 875 | for (const id of terminalIds) { | ||
| 876 | const trimmedId = id.trim(); | ||
| 877 | if (trimmedId.length === 0 || seen.has(trimmedId)) continue; | ||
| 878 | seen.add(trimmedId); | ||
| 879 | normalizedIds.push(trimmedId); | ||
| 880 | } | ||
| 881 | return normalizedIds; | ||
| 894 | }, [terminalIds]); | 882 | }, [terminalIds]); |
| 895 | 883 | ||
| 896 | const resolvedActiveTerminalId = normalizedTerminalIds.includes(activeTerminalId) | 884 | const resolvedActiveTerminalId = |
| 897 | ? activeTerminalId | 885 | normalizedTerminalIds.length === 0 |
| 898 | : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | 886 | ? "" |
| 887 | : normalizedTerminalIds.includes(activeTerminalId) | ||
| 888 | ? activeTerminalId | ||
| 889 | : (normalizedTerminalIds[0] ?? ""); | ||
| 899 | 890 | ||
| 900 | const resolvedTerminalGroups = useMemo(() => { | 891 | const resolvedTerminalGroups = useMemo(() => { |
| 892 | if (normalizedTerminalIds.length === 0) { | ||
| 893 | return []; | ||
| 894 | } | ||
| 901 | const validTerminalIdSet = new Set(normalizedTerminalIds); | 895 | const validTerminalIdSet = new Set(normalizedTerminalIds); |
| 902 | const assignedTerminalIds = new Set<string>(); | 896 | const assignedTerminalIds = new Set<string>(); |
| 903 | const usedGroupIds = new Set<string>(); | 897 | const usedGroupIds = new Set<string>(); |
| ... | @@ -918,13 +912,17 @@ export default function ThreadTerminalDrawer({ | ... | @@ -918,13 +912,17 @@ export default function ThreadTerminalDrawer({ |
| 918 | }; | 912 | }; |
| 919 | 913 | ||
| 920 | for (const terminalGroup of terminalGroups) { | 914 | for (const terminalGroup of terminalGroups) { |
| 921 | const nextTerminalIds = [ | 915 | const nextTerminalIds: string[] = []; |
| 922 | ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), | 916 | const seenGroupTerminalIds = new Set<string>(); |
| 923 | ].filter((terminalId) => { | 917 | for (const id of terminalGroup.terminalIds) { |
| 924 | if (!validTerminalIdSet.has(terminalId)) return false; | 918 | const terminalId = id.trim(); |
| 925 | if (assignedTerminalIds.has(terminalId)) return false; | 919 | if (terminalId.length === 0) continue; |
| 926 | return true; | 920 | if (seenGroupTerminalIds.has(terminalId)) continue; |
| 927 | }); | 921 | seenGroupTerminalIds.add(terminalId); |
| 922 | if (!validTerminalIdSet.has(terminalId)) continue; | ||
| 923 | if (assignedTerminalIds.has(terminalId)) continue; | ||
| 924 | nextTerminalIds.push(terminalId); | ||
| 925 | } | ||
| 928 | if (nextTerminalIds.length === 0) continue; | 926 | if (nextTerminalIds.length === 0) continue; |
| 929 | 927 | ||
| 930 | for (const terminalId of nextTerminalIds) { | 928 | for (const terminalId of nextTerminalIds) { |
| ... | @@ -934,7 +932,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -934,7 +932,7 @@ export default function ThreadTerminalDrawer({ |
| 934 | const baseGroupId = | 932 | const baseGroupId = |
| 935 | terminalGroup.id.trim().length > 0 | 933 | terminalGroup.id.trim().length > 0 |
| 936 | ? terminalGroup.id.trim() | 934 | ? terminalGroup.id.trim() |
| 937 | : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; | 935 | : `group-${nextTerminalIds[0] ?? normalizedTerminalIds[0] ?? ""}`; |
| 938 | nextGroups.push({ | 936 | nextGroups.push({ |
| 939 | id: assignUniqueGroupId(baseGroupId), | 937 | id: assignUniqueGroupId(baseGroupId), |
| 940 | terminalIds: nextTerminalIds, | 938 | terminalIds: nextTerminalIds, |
| ... | @@ -949,17 +947,17 @@ export default function ThreadTerminalDrawer({ | ... | @@ -949,17 +947,17 @@ export default function ThreadTerminalDrawer({ |
| 949 | }); | 947 | }); |
| 950 | } | 948 | } |
| 951 | 949 | ||
| 952 | if (nextGroups.length > 0) { | 950 | const terminalOrderIndex = new Map( |
| 953 | return nextGroups; | 951 | normalizedTerminalIds.map((id, index) => [id, index] as const), |
| 954 | } | 952 | ); |
| 953 | nextGroups.sort((left, right) => { | ||
| 954 | const rank = (ids: readonly string[]) => | ||
| 955 | Math.min(...ids.map((id) => terminalOrderIndex.get(id) ?? Number.POSITIVE_INFINITY)); | ||
| 956 | return rank(left.terminalIds) - rank(right.terminalIds); | ||
| 957 | }); | ||
| 955 | 958 | ||
| 956 | return [ | 959 | return nextGroups; |
| 957 | { | 960 | }, [normalizedTerminalIds, terminalGroups]); |
| 958 | id: `group-${resolvedActiveTerminalId}`, | ||
| 959 | terminalIds: [resolvedActiveTerminalId], | ||
| 960 | }, | ||
| 961 | ]; | ||
| 962 | }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]); | ||
| 963 | 961 | ||
| 964 | const resolvedActiveGroupIndex = useMemo(() => { | 962 | const resolvedActiveGroupIndex = useMemo(() => { |
| 965 | const indexById = resolvedTerminalGroups.findIndex( | 963 | const indexById = resolvedTerminalGroups.findIndex( |
| ... | @@ -972,21 +970,33 @@ export default function ThreadTerminalDrawer({ | ... | @@ -972,21 +970,33 @@ export default function ThreadTerminalDrawer({ |
| 972 | return indexByTerminal >= 0 ? indexByTerminal : 0; | 970 | return indexByTerminal >= 0 ? indexByTerminal : 0; |
| 973 | }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]); | 971 | }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]); |
| 974 | 972 | ||
| 975 | const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [ | 973 | const visibleTerminalIds = |
| 976 | resolvedActiveTerminalId, | 974 | resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? |
| 977 | ]; | 975 | (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); |
| 978 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; | 976 | const hasTerminalSidebar = normalizedTerminalIds.length > 1; |
| 979 | const isSplitView = visibleTerminalIds.length > 1; | 977 | const isSplitView = visibleTerminalIds.length > 1; |
| 980 | const showGroupHeaders = | 978 | const showGroupHeaders = |
| 981 | resolvedTerminalGroups.length > 1 || | 979 | resolvedTerminalGroups.length > 1 || |
| 982 | resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); | 980 | resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); |
| 983 | const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; | 981 | const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; |
| 984 | const terminalLabelById = useMemo( | 982 | const terminalLabelById = useMemo(() => { |
| 985 | () => | 983 | const next = new Map<string, string>(); |
| 986 | new Map( | 984 | for (const terminalId of normalizedTerminalIds) { |
| 987 | normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]), | 985 | next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); |
| 988 | ), | 986 | } |
| 989 | [normalizedTerminalIds], | 987 | return next; |
| 988 | }, [normalizedTerminalIds, terminalLabelsById]); | ||
| 989 | const resolveTerminalLaunchLocation = useCallback( | ||
| 990 | (terminalId: string): TerminalLaunchLocation => { | ||
| 991 | return ( | ||
| 992 | terminalLaunchLocationsById?.get(terminalId) ?? { | ||
| 993 | cwd, | ||
| 994 | ...(worktreePath !== undefined ? { worktreePath } : {}), | ||
| 995 | ...(runtimeEnv ? { runtimeEnv } : {}), | ||
| 996 | } | ||
| 997 | ); | ||
| 998 | }, | ||
| 999 | [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], | ||
| 990 | ); | 1000 | ); |
| 991 | const splitTerminalActionLabel = hasReachedSplitLimit | 1001 | const splitTerminalActionLabel = hasReachedSplitLimit |
| 992 | ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` | 1002 | ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` |
| ... | @@ -1109,6 +1119,35 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1109,6 +1119,35 @@ export default function ThreadTerminalDrawer({ |
| 1109 | }; | 1119 | }; |
| 1110 | }, [syncHeight]); | 1120 | }, [syncHeight]); |
| 1111 | 1121 | ||
| 1122 | if (normalizedTerminalIds.length === 0) { | ||
| 1123 | return ( | ||
| 1124 | <aside | ||
| 1125 | className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" | ||
| 1126 | style={{ height: `${drawerHeight}px` }} | ||
| 1127 | > | ||
| 1128 | <div | ||
| 1129 | className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" | ||
| 1130 | onPointerDown={handleResizePointerDown} | ||
| 1131 | onPointerMove={handleResizePointerMove} | ||
| 1132 | onPointerUp={handleResizePointerEnd} | ||
| 1133 | onPointerCancel={handleResizePointerEnd} | ||
| 1134 | /> | ||
| 1135 | <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center text-sm text-muted-foreground"> | ||
| 1136 | <p>No terminal sessions for this thread yet.</p> | ||
| 1137 | <button | ||
| 1138 | type="button" | ||
| 1139 | className="rounded-md border border-border/80 bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent" | ||
| 1140 | onClick={onNewTerminalAction} | ||
| 1141 | > | ||
| 1142 | {newTerminalActionLabel} | ||
| 1143 | </button> | ||
| 1144 | </div> | ||
| 1145 | </aside> | ||
| 1146 | ); | ||
| 1147 | } | ||
| 1148 | |||
| 1149 | const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); | ||
| 1150 | |||
| 1112 | return ( | 1151 | return ( |
| 1113 | <aside | 1152 | <aside |
| 1114 | className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" | 1153 | className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background" |
| ... | @@ -1166,38 +1205,47 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1166,38 +1205,47 @@ export default function ThreadTerminalDrawer({ |
| 1166 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, | 1205 | gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`, |
| 1167 | }} | 1206 | }} |
| 1168 | > | 1207 | > |
| 1169 | {visibleTerminalIds.map((terminalId) => ( | 1208 | {visibleTerminalIds.map((terminalId) => { |
| 1170 | <div | 1209 | const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); |
| 1171 | key={terminalId} | 1210 | return ( |
| 1172 | className={`min-h-0 min-w-0 border-l first:border-l-0 ${ | 1211 | <div |
| 1173 | terminalId === resolvedActiveTerminalId ? "border-border" : "border-border/70" | 1212 | key={terminalId} |
| 1174 | }`} | 1213 | className={`min-h-0 min-w-0 border-l first:border-l-0 ${ |
| 1175 | onMouseDown={() => { | 1214 | terminalId === resolvedActiveTerminalId |
| 1176 | if (terminalId !== resolvedActiveTerminalId) { | 1215 | ? "border-border" |
| 1177 | onActiveTerminalChange(terminalId); | 1216 | : "border-border/70" |
| 1178 | } | 1217 | }`} |
| 1179 | }} | 1218 | onMouseDown={() => { |
| 1180 | > | 1219 | if (terminalId !== resolvedActiveTerminalId) { |
| 1181 | <div className="h-full p-1"> | 1220 | onActiveTerminalChange(terminalId); |
| 1182 | <TerminalViewport | 1221 | } |
| 1183 | threadRef={threadRef} | 1222 | }} |
| 1184 | threadId={threadId} | 1223 | > |
| 1185 | terminalId={terminalId} | 1224 | <div className="h-full p-1"> |
| 1186 | terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} | 1225 | <TerminalViewport |
| 1187 | cwd={cwd} | 1226 | threadRef={threadRef} |
| 1188 | {...(worktreePath !== undefined ? { worktreePath } : {})} | 1227 | threadId={threadId} |
| 1189 | {...(runtimeEnv ? { runtimeEnv } : {})} | 1228 | terminalId={terminalId} |
| 1190 | onSessionExited={() => onCloseTerminal(terminalId)} | 1229 | terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} |
| 1191 | onAddTerminalContext={onAddTerminalContext} | 1230 | cwd={terminalLaunchLocation.cwd} |
| 1192 | focusRequestId={focusRequestId} | 1231 | {...(terminalLaunchLocation.worktreePath !== undefined |
| 1193 | autoFocus={terminalId === resolvedActiveTerminalId} | 1232 | ? { worktreePath: terminalLaunchLocation.worktreePath } |
| 1194 | resizeEpoch={resizeEpoch} | 1233 | : {})} |
| 1195 | drawerHeight={drawerHeight} | 1234 | {...(terminalLaunchLocation.runtimeEnv |
| 1196 | keybindings={keybindings} | 1235 | ? { runtimeEnv: terminalLaunchLocation.runtimeEnv } |
| 1197 | /> | 1236 | : {})} |
| 1237 | onSessionExited={() => onCloseTerminal(terminalId)} | ||
| 1238 | onAddTerminalContext={onAddTerminalContext} | ||
| 1239 | focusRequestId={focusRequestId} | ||
| 1240 | autoFocus={terminalId === resolvedActiveTerminalId} | ||
| 1241 | resizeEpoch={resizeEpoch} | ||
| 1242 | drawerHeight={drawerHeight} | ||
| 1243 | keybindings={keybindings} | ||
| 1244 | /> | ||
| 1245 | </div> | ||
| 1198 | </div> | 1246 | </div> |
| 1199 | </div> | 1247 | ); |
| 1200 | ))} | 1248 | })} |
| 1201 | </div> | 1249 | </div> |
| 1202 | ) : ( | 1250 | ) : ( |
| 1203 | <div className="h-full p-1"> | 1251 | <div className="h-full p-1"> |
| ... | @@ -1207,9 +1255,13 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1207,9 +1255,13 @@ export default function ThreadTerminalDrawer({ |
| 1207 | threadId={threadId} | 1255 | threadId={threadId} |
| 1208 | terminalId={resolvedActiveTerminalId} | 1256 | terminalId={resolvedActiveTerminalId} |
| 1209 | terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} | 1257 | terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} |
| 1210 | cwd={cwd} | 1258 | cwd={activeTerminalLaunchLocation.cwd} |
| 1211 | {...(worktreePath !== undefined ? { worktreePath } : {})} | 1259 | {...(activeTerminalLaunchLocation.worktreePath !== undefined |
| 1212 | {...(runtimeEnv ? { runtimeEnv } : {})} | 1260 | ? { worktreePath: activeTerminalLaunchLocation.worktreePath } |
| 1261 | : {})} | ||
| 1262 | {...(activeTerminalLaunchLocation.runtimeEnv | ||
| 1263 | ? { runtimeEnv: activeTerminalLaunchLocation.runtimeEnv } | ||
| 1264 | : {})} | ||
| 1213 | onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} | 1265 | onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 1214 | onAddTerminalContext={onAddTerminalContext} | 1266 | onAddTerminalContext={onAddTerminalContext} |
| 1215 | focusRequestId={focusRequestId} | 1267 | focusRequestId={focusRequestId} |
| ... | @@ -1274,9 +1326,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1274,9 +1326,7 @@ export default function ThreadTerminalDrawer({ |
| 1274 | }`} | 1326 | }`} |
| 1275 | onClick={() => onActiveTerminalChange(groupActiveTerminalId)} | 1327 | onClick={() => onActiveTerminalChange(groupActiveTerminalId)} |
| 1276 | > | 1328 | > |
| 1277 | {terminalGroup.terminalIds.length > 1 | 1329 | Group {groupIndex + 1} |
| 1278 | ? `Split ${groupIndex + 1}` | ||
| 1279 | : `Terminal ${groupIndex + 1}`} | ||
| 1280 | </button> | 1330 | </button> |
| 1281 | )} | 1331 | )} |
| 1282 | 1332 |
apps/web/src/components/chat/ChatComposer.tsx+9-28| ... | @@ -2,7 +2,6 @@ import type { | ... | @@ -2,7 +2,6 @@ import type { |
| 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 { | ... | @@ -29,9 +28,6 @@ import { |
| 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 { | ... | @@ -55,6 +51,7 @@ import { |
| 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< | ... | @@ -136,8 +133,6 @@ const runtimeModeConfig: Record< |
| 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"]', |
| ... | @@ -840,27 +835,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -840,27 +835,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 840 | const composerTriggerKind = composerTrigger?.kind ?? null; | 835 | const composerTriggerKind = composerTrigger?.kind ?? null; |
| 841 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; | 836 | const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; |
| 842 | const isPathTrigger = composerTriggerKind === "path"; | 837 | const isPathTrigger = composerTriggerKind === "path"; |
| 843 | const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( | 838 | const workspaceEntries = useComposerPathSearch({ |
| 844 | pathTriggerQuery, | 839 | environmentId, |
| 845 | { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, | 840 | cwd: isPathTrigger ? gitCwd : null, |
| 846 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 841 | query: isPathTrigger ? pathTriggerQuery : null, |
| 847 | ); | 842 | }); |
| 848 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | ||
| 849 | const workspaceEntriesQuery = useQuery( | ||
| 850 | projectSearchEntriesQueryOptions({ | ||
| 851 | environmentId, | ||
| 852 | cwd: gitCwd, | ||
| 853 | query: effectivePathQuery, | ||
| 854 | enabled: isPathTrigger, | ||
| 855 | limit: 80, | ||
| 856 | }), | ||
| 857 | ); | ||
| 858 | const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; | ||
| 859 | 843 | ||
| 860 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { | 844 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { |
| 861 | if (!composerTrigger) return []; | 845 | if (!composerTrigger) return []; |
| 862 | if (composerTrigger.kind === "path") { | 846 | if (composerTrigger.kind === "path") { |
| 863 | return workspaceEntries.map((entry) => ({ | 847 | return workspaceEntries.entries.map((entry) => ({ |
| 864 | id: `path:${entry.kind}:${entry.path}`, | 848 | id: `path:${entry.kind}:${entry.path}`, |
| 865 | type: "path", | 849 | type: "path", |
| 866 | path: entry.path, | 850 | path: entry.path, |
| ... | @@ -926,7 +910,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -926,7 +910,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 926 | ); | 910 | ); |
| 927 | } | 911 | } |
| 928 | return []; | 912 | return []; |
| 929 | }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]); | 913 | }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); |
| 930 | 914 | ||
| 931 | const composerMenuOpen = Boolean(composerTrigger); | 915 | const composerMenuOpen = Boolean(composerTrigger); |
| 932 | const composerMenuSearchKey = composerTrigger | 916 | const composerMenuSearchKey = composerTrigger |
| ... | @@ -991,10 +975,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -991,10 +975,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 991 | ]); | 975 | ]); |
| 992 | 976 | ||
| 993 | const isComposerMenuLoading = | 977 | const isComposerMenuLoading = |
| 994 | composerTriggerKind === "path" && | 978 | composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; |
| 995 | ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || | ||
| 996 | workspaceEntriesQuery.isLoading || | ||
| 997 | workspaceEntriesQuery.isFetching); | ||
| 998 | const composerMenuEmptyState = useMemo(() => { | 979 | const composerMenuEmptyState = useMemo(() => { |
| 999 | if (composerTriggerKind === "skill") { | 980 | if (composerTriggerKind === "skill") { |
| 1000 | return "No skills found. Try / to browse provider commands."; | 981 | return "No skills found. Try / to browse provider commands."; |
apps/web/src/components/chat/MessagesTimeline.test.tsx+58| ... | @@ -27,6 +27,25 @@ vi.mock("@legendapp/list/react", async () => { | ... | @@ -27,6 +27,25 @@ vi.mock("@legendapp/list/react", async () => { |
| 27 | return { LegendList }; | 27 | return { LegendList }; |
| 28 | }); | 28 | }); |
| 29 | 29 | ||
| 30 | function MockFileDiff(props: { | ||
| 31 | fileDiff: { name?: string | null; prevName?: string | null }; | ||
| 32 | renderCustomHeader?: (fileDiff: { | ||
| 33 | name?: string | null; | ||
| 34 | prevName?: string | null; | ||
| 35 | }) => React.ReactNode; | ||
| 36 | }) { | ||
| 37 | return ( | ||
| 38 | <div data-testid="file-diff"> | ||
| 39 | {props.renderCustomHeader?.(props.fileDiff)} | ||
| 40 | {props.fileDiff.name ?? props.fileDiff.prevName ?? "diff"} | ||
| 41 | </div> | ||
| 42 | ); | ||
| 43 | } | ||
| 44 | |||
| 45 | vi.mock("@pierre/diffs/react", () => { | ||
| 46 | return { FileDiff: MockFileDiff }; | ||
| 47 | }); | ||
| 48 | |||
| 30 | function matchMedia() { | 49 | function matchMedia() { |
| 31 | return { | 50 | return { |
| 32 | matches: false, | 51 | matches: false, |
| ... | @@ -238,4 +257,43 @@ describe("MessagesTimeline", () => { | ... | @@ -238,4 +257,43 @@ describe("MessagesTimeline", () => { |
| 238 | expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); | 257 | expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); |
| 239 | expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); | 258 | expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); |
| 240 | }); | 259 | }); |
| 260 | |||
| 261 | it("renders review comment contexts as structured cards instead of raw tags", async () => { | ||
| 262 | const { MessagesTimeline } = await import("./MessagesTimeline"); | ||
| 263 | const markup = renderToStaticMarkup( | ||
| 264 | <MessagesTimeline | ||
| 265 | {...buildProps()} | ||
| 266 | timelineEntries={[ | ||
| 267 | { | ||
| 268 | id: "entry-1", | ||
| 269 | kind: "message", | ||
| 270 | createdAt: "2026-03-17T19:12:28.000Z", | ||
| 271 | message: { | ||
| 272 | id: MessageId.make("message-2"), | ||
| 273 | role: "user", | ||
| 274 | text: [ | ||
| 275 | '<review_comment sectionId="turn:2" sectionTitle="Turn 2" filePath="apps/web/src/lib/contextWindow.test.ts" startIndex="3" endIndex="14" rangeLabel="+47 to +58">', | ||
| 276 | "Wadduo", | ||
| 277 | "```diff", | ||
| 278 | "@@ -0,0 +47,2 @@", | ||
| 279 | '+ it("keeps valid zero-usage snapshots", () => {', | ||
| 280 | "+ expect(snapshot).not.toBeNull();", | ||
| 281 | "```", | ||
| 282 | "</review_comment>", | ||
| 283 | ].join("\n"), | ||
| 284 | createdAt: "2026-03-17T19:12:28.000Z", | ||
| 285 | streaming: false, | ||
| 286 | }, | ||
| 287 | }, | ||
| 288 | ]} | ||
| 289 | />, | ||
| 290 | ); | ||
| 291 | |||
| 292 | expect(markup).toContain("contextWindow.test.ts"); | ||
| 293 | expect(markup).toContain("Wadduo"); | ||
| 294 | expect(markup).toContain('data-testid="file-diff"'); | ||
| 295 | expect(markup).not.toContain(">Review comment<"); | ||
| 296 | expect(markup).not.toContain("&lt;review_comment"); | ||
| 297 | expect(markup).not.toContain("&lt;/review_comment&gt;"); | ||
| 298 | }); | ||
| 241 | }); | 299 | }); |
apps/web/src/components/chat/MessagesTimeline.tsx+73| ... | @@ -16,9 +16,15 @@ import { | ... | @@ -16,9 +16,15 @@ import { |
| 16 | type ReactNode, | 16 | type ReactNode, |
| 17 | } from "react"; | 17 | } from "react"; |
| 18 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | 18 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 19 | import { FileDiff } from "@pierre/diffs/react"; | ||
| 19 | import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; | 20 | import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; |
| 20 | import { type TurnDiffSummary } from "../../types"; | 21 | import { type TurnDiffSummary } from "../../types"; |
| 21 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; | 22 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; |
| 23 | import { | ||
| 24 | getRenderablePatch, | ||
| 25 | resolveDiffThemeName, | ||
| 26 | resolveFileDiffPath, | ||
| 27 | } from "../../lib/diffRendering"; | ||
| 22 | import ChatMarkdown from "../ChatMarkdown"; | 28 | import ChatMarkdown from "../ChatMarkdown"; |
| 23 | import { | 29 | import { |
| 24 | BotIcon, | 30 | BotIcon, |
| ... | @@ -67,6 +73,11 @@ import { | ... | @@ -67,6 +73,11 @@ import { |
| 67 | } from "./userMessageTerminalContexts"; | 73 | } from "./userMessageTerminalContexts"; |
| 68 | import { SkillInlineText } from "./SkillInlineText"; | 74 | import { SkillInlineText } from "./SkillInlineText"; |
| 69 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; | 75 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 76 | import { | ||
| 77 | buildReviewCommentRenderablePatch, | ||
| 78 | parseReviewCommentMessageSegments, | ||
| 79 | type ReviewCommentContext, | ||
| 80 | } from "../../reviewCommentContext"; | ||
| 70 | 81 | ||
| 71 | // --------------------------------------------------------------------------- | 82 | // --------------------------------------------------------------------------- |
| 72 | // Context — shared state consumed by every row component via Context. | 83 | // Context — shared state consumed by every row component via Context. |
| ... | @@ -837,6 +848,25 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -837,6 +848,25 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 837 | terminalContexts: ParsedTerminalContextEntry[]; | 848 | terminalContexts: ParsedTerminalContextEntry[]; |
| 838 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | 849 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 839 | }) { | 850 | }) { |
| 851 | const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); | ||
| 852 | if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { | ||
| 853 | return ( | ||
| 854 | <div className="space-y-3 text-sm leading-relaxed text-foreground"> | ||
| 855 | {reviewCommentSegments.map((segment) => | ||
| 856 | segment.kind === "text" ? ( | ||
| 857 | segment.text.trim().length > 0 ? ( | ||
| 858 | <div key={segment.id} className="whitespace-pre-wrap wrap-break-word"> | ||
| 859 | <SkillInlineText text={segment.text.trim()} skills={props.skills} /> | ||
| 860 | </div> | ||
| 861 | ) : null | ||
| 862 | ) : ( | ||
| 863 | <UserMessageReviewCommentCard key={segment.comment.id} comment={segment.comment} /> | ||
| 864 | ), | ||
| 865 | )} | ||
| 866 | </div> | ||
| 867 | ); | ||
| 868 | } | ||
| 869 | |||
| 840 | if (props.terminalContexts.length > 0) { | 870 | if (props.terminalContexts.length > 0) { |
| 841 | const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( | 871 | const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( |
| 842 | props.text, | 872 | props.text, |
| ... | @@ -930,6 +960,49 @@ const UserMessageBody = memo(function UserMessageBody(props: { | ... | @@ -930,6 +960,49 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 930 | ); | 960 | ); |
| 931 | }); | 961 | }); |
| 932 | 962 | ||
| 963 | function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) { | ||
| 964 | const ctx = use(TimelineRowCtx); | ||
| 965 | const renderablePatch = getRenderablePatch( | ||
| 966 | buildReviewCommentRenderablePatch(comment), | ||
| 967 | `review-comment:${comment.id}`, | ||
| 968 | ); | ||
| 969 | |||
| 970 | return ( | ||
| 971 | <div className="space-y-2 rounded-lg border border-border/70 bg-background/70 p-3"> | ||
| 972 | <div className="space-y-1"> | ||
| 973 | <div className="text-xs font-medium text-foreground"> | ||
| 974 | {formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)} | ||
| 975 | </div> | ||
| 976 | <div className="text-[11px] text-muted-foreground"> | ||
| 977 | {comment.sectionTitle} · {comment.rangeLabel} | ||
| 978 | </div> | ||
| 979 | </div> | ||
| 980 | {comment.text.length > 0 && ( | ||
| 981 | <div className="whitespace-pre-wrap wrap-break-word text-sm"> | ||
| 982 | <SkillInlineText text={comment.text} skills={ctx.skills} /> | ||
| 983 | </div> | ||
| 984 | )} | ||
| 985 | {renderablePatch?.kind === "files" && | ||
| 986 | renderablePatch.files.map((fileDiff) => ( | ||
| 987 | <FileDiff | ||
| 988 | key={resolveFileDiffPath(fileDiff)} | ||
| 989 | fileDiff={fileDiff} | ||
| 990 | options={{ | ||
| 991 | collapsed: false, | ||
| 992 | diffStyle: "unified", | ||
| 993 | theme: resolveDiffThemeName(ctx.resolvedTheme), | ||
| 994 | }} | ||
| 995 | /> | ||
| 996 | ))} | ||
| 997 | {renderablePatch?.kind === "raw" && ( | ||
| 998 | <pre className="overflow-x-auto rounded-md bg-muted/40 p-2 text-xs"> | ||
| 999 | {renderablePatch.text} | ||
| 1000 | </pre> | ||
| 1001 | )} | ||
| 1002 | </div> | ||
| 1003 | ); | ||
| 1004 | } | ||
| 1005 | |||
| 933 | // --------------------------------------------------------------------------- | 1006 | // --------------------------------------------------------------------------- |
| 934 | // Structural sharing — reuse old row references when data hasn't changed | 1007 | // Structural sharing — reuse old row references when data hasn't changed |
| 935 | // so LegendList (and React) can skip re-rendering unchanged items. | 1008 | // so LegendList (and React) can skip re-rendering unchanged items. |
apps/web/src/components/settings/ConnectionsSettings.tsx+1-1| ... | @@ -17,6 +17,7 @@ import { | ... | @@ -17,6 +17,7 @@ import { |
| 17 | type DesktopServerExposureState, | 17 | type DesktopServerExposureState, |
| 18 | type EnvironmentId, | 18 | type EnvironmentId, |
| 19 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 20 | import { WsRpcClient } from "@t3tools/client-runtime"; | ||
| 20 | import * as DateTime from "effect/DateTime"; | 21 | import * as DateTime from "effect/DateTime"; |
| 21 | 22 | ||
| 22 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | 23 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| ... | @@ -82,7 +83,6 @@ import { | ... | @@ -82,7 +83,6 @@ import { |
| 82 | type ServerClientSessionRecord, | 83 | type ServerClientSessionRecord, |
| 83 | type ServerPairingLinkRecord, | 84 | type ServerPairingLinkRecord, |
| 84 | } from "~/environments/primary"; | 85 | } from "~/environments/primary"; |
| 85 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | ||
| 86 | import { | 86 | import { |
| 87 | type SavedEnvironmentRecord, | 87 | type SavedEnvironmentRecord, |
| 88 | type SavedEnvironmentRuntimeState, | 88 | type SavedEnvironmentRuntimeState, |
apps/web/src/environmentApi.ts+7-2| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import type { WsRpcClient } from "./rpc/wsRpcClient"; | 3 | import type { WsRpcClient } from "@t3tools/client-runtime"; |
| 4 | import { readEnvironmentConnection } from "./environments/runtime"; | 4 | import { readEnvironmentConnection } from "./environments/runtime"; |
| 5 | 5 | ||
| 6 | const environmentApiOverridesForTests = new Map<EnvironmentId, EnvironmentApi>(); | 6 | const environmentApiOverridesForTests = new Map<EnvironmentId, EnvironmentApi>(); |
| ... | @@ -9,12 +9,14 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { | ... | @@ -9,12 +9,14 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 9 | return { | 9 | return { |
| 10 | terminal: { | 10 | terminal: { |
| 11 | open: (input) => rpcClient.terminal.open(input as never), | 11 | open: (input) => rpcClient.terminal.open(input as never), |
| 12 | attach: (input, callback, options) => | ||
| 13 | rpcClient.terminal.attach(input as never, callback, options), | ||
| 12 | write: (input) => rpcClient.terminal.write(input as never), | 14 | write: (input) => rpcClient.terminal.write(input as never), |
| 13 | resize: (input) => rpcClient.terminal.resize(input as never), | 15 | resize: (input) => rpcClient.terminal.resize(input as never), |
| 14 | clear: (input) => rpcClient.terminal.clear(input as never), | 16 | clear: (input) => rpcClient.terminal.clear(input as never), |
| 15 | restart: (input) => rpcClient.terminal.restart(input as never), | 17 | restart: (input) => rpcClient.terminal.restart(input as never), |
| 16 | close: (input) => rpcClient.terminal.close(input as never), | 18 | close: (input) => rpcClient.terminal.close(input as never), |
| 17 | onEvent: (callback) => rpcClient.terminal.onEvent(callback), | 19 | onMetadata: (callback, options) => rpcClient.terminal.onMetadata(callback, options), |
| 18 | }, | 20 | }, |
| 19 | projects: { | 21 | projects: { |
| 20 | searchEntries: rpcClient.projects.searchEntries, | 22 | searchEntries: rpcClient.projects.searchEntries, |
| ... | @@ -43,6 +45,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { | ... | @@ -43,6 +45,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 43 | resolvePullRequest: rpcClient.git.resolvePullRequest, | 45 | resolvePullRequest: rpcClient.git.resolvePullRequest, |
| 44 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, | 46 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, |
| 45 | }, | 47 | }, |
| 48 | review: { | ||
| 49 | getDiffPreview: rpcClient.review.getDiffPreview, | ||
| 50 | }, | ||
| 46 | orchestration: { | 51 | orchestration: { |
| 47 | dispatchCommand: rpcClient.orchestration.dispatchCommand, | 52 | dispatchCommand: rpcClient.orchestration.dispatchCommand, |
| 48 | getTurnDiff: rpcClient.orchestration.getTurnDiff, | 53 | getTurnDiff: rpcClient.orchestration.getTurnDiff, |
apps/web/src/environments/remote/api.test.ts deleted-248| ... | @@ -1,248 +0,0 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | bootstrapRemoteBearerSession, | ||
| 5 | fetchRemoteEnvironmentDescriptor, | ||
| 6 | fetchRemoteSessionState, | ||
| 7 | issueRemoteWebSocketToken, | ||
| 8 | resolveRemoteWebSocketConnectionUrl, | ||
| 9 | } from "./api"; | ||
| 10 | import { resolveRemotePairingTarget } from "./target"; | ||
| 11 | |||
| 12 | const originalFetch = globalThis.fetch; | ||
| 13 | |||
| 14 | beforeEach(() => { | ||
| 15 | Object.defineProperty(globalThis, "window", { | ||
| 16 | configurable: true, | ||
| 17 | value: { | ||
| 18 | location: { | ||
| 19 | origin: "https://app.example.com", | ||
| 20 | }, | ||
| 21 | }, | ||
| 22 | }); | ||
| 23 | vi.restoreAllMocks(); | ||
| 24 | }); | ||
| 25 | |||
| 26 | describe("remote environment api", () => { | ||
| 27 | it("derives backend urls and token from a pairing url", () => { | ||
| 28 | expect( | ||
| 29 | resolveRemotePairingTarget({ | ||
| 30 | pairingUrl: "https://remote.example.com/pair#token=pairing-token", | ||
| 31 | }), | ||
| 32 | ).toEqual({ | ||
| 33 | credential: "pairing-token", | ||
| 34 | httpBaseUrl: "https://remote.example.com/", | ||
| 35 | wsBaseUrl: "wss://remote.example.com/", | ||
| 36 | }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("accepts pairing urls that still use a query token", () => { | ||
| 40 | expect( | ||
| 41 | resolveRemotePairingTarget({ | ||
| 42 | pairingUrl: "https://remote.example.com/pair?token=pairing-token", | ||
| 43 | }), | ||
| 44 | ).toEqual({ | ||
| 45 | credential: "pairing-token", | ||
| 46 | httpBaseUrl: "https://remote.example.com/", | ||
| 47 | wsBaseUrl: "wss://remote.example.com/", | ||
| 48 | }); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("derives backend urls and token from a hosted app pairing link", () => { | ||
| 52 | expect( | ||
| 53 | resolveRemotePairingTarget({ | ||
| 54 | pairingUrl: | ||
| 55 | "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%3A44342%2F#token=pairing-token", | ||
| 56 | }), | ||
| 57 | ).toEqual({ | ||
| 58 | credential: "pairing-token", | ||
| 59 | httpBaseUrl: "https://desktop.tailnet.ts.net:44342/", | ||
| 60 | wsBaseUrl: "wss://desktop.tailnet.ts.net:44342/", | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("derives backend urls from a host and pairing code", () => { | ||
| 65 | expect( | ||
| 66 | resolveRemotePairingTarget({ | ||
| 67 | host: "https://remote.example.com", | ||
| 68 | pairingCode: "pairing-token", | ||
| 69 | }), | ||
| 70 | ).toEqual({ | ||
| 71 | credential: "pairing-token", | ||
| 72 | httpBaseUrl: "https://remote.example.com/", | ||
| 73 | wsBaseUrl: "wss://remote.example.com/", | ||
| 74 | }); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it("preserves host ports when normalizing a bare host input", () => { | ||
| 78 | expect( | ||
| 79 | resolveRemotePairingTarget({ | ||
| 80 | host: "myserver.com:3000", | ||
| 81 | pairingCode: "pairing-token", | ||
| 82 | }), | ||
| 83 | ).toEqual({ | ||
| 84 | credential: "pairing-token", | ||
| 85 | httpBaseUrl: "https://myserver.com:3000/", | ||
| 86 | wsBaseUrl: "wss://myserver.com:3000/", | ||
| 87 | }); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("bootstraps bearer auth against a remote backend", async () => { | ||
| 91 | const fetchMock = vi.fn().mockResolvedValue( | ||
| 92 | new Response( | ||
| 93 | JSON.stringify({ | ||
| 94 | authenticated: true, | ||
| 95 | role: "client", | ||
| 96 | sessionMethod: "bearer-session-token", | ||
| 97 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 98 | sessionToken: "bearer-token", | ||
| 99 | }), | ||
| 100 | { status: 200 }, | ||
| 101 | ), | ||
| 102 | ); | ||
| 103 | globalThis.fetch = fetchMock as typeof fetch; | ||
| 104 | |||
| 105 | await expect( | ||
| 106 | bootstrapRemoteBearerSession({ | ||
| 107 | httpBaseUrl: "https://remote.example.com/", | ||
| 108 | credential: "pairing-token", | ||
| 109 | }), | ||
| 110 | ).resolves.toMatchObject({ | ||
| 111 | sessionMethod: "bearer-session-token", | ||
| 112 | sessionToken: "bearer-token", | ||
| 113 | }); | ||
| 114 | |||
| 115 | expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/bootstrap/bearer", { | ||
| 116 | method: "POST", | ||
| 117 | headers: { | ||
| 118 | "content-type": "application/json", | ||
| 119 | }, | ||
| 120 | body: JSON.stringify({ | ||
| 121 | credential: "pairing-token", | ||
| 122 | }), | ||
| 123 | }); | ||
| 124 | }); | ||
| 125 | |||
| 126 | it("loads remote session state and websocket tokens over bearer auth", async () => { | ||
| 127 | const fetchMock = vi | ||
| 128 | .fn() | ||
| 129 | .mockResolvedValueOnce( | ||
| 130 | new Response( | ||
| 131 | JSON.stringify({ | ||
| 132 | environmentId: "environment-remote", | ||
| 133 | label: "Remote environment", | ||
| 134 | platform: { | ||
| 135 | os: "linux", | ||
| 136 | arch: "x64", | ||
| 137 | }, | ||
| 138 | serverVersion: "0.0.0-test", | ||
| 139 | capabilities: { | ||
| 140 | repositoryIdentity: true, | ||
| 141 | }, | ||
| 142 | }), | ||
| 143 | { status: 200 }, | ||
| 144 | ), | ||
| 145 | ) | ||
| 146 | .mockResolvedValueOnce( | ||
| 147 | new Response( | ||
| 148 | JSON.stringify({ | ||
| 149 | authenticated: true, | ||
| 150 | auth: { | ||
| 151 | policy: "remote-reachable", | ||
| 152 | bootstrapMethods: ["one-time-token"], | ||
| 153 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | ||
| 154 | sessionCookieName: "t3_session", | ||
| 155 | }, | ||
| 156 | role: "client", | ||
| 157 | sessionMethod: "bearer-session-token", | ||
| 158 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 159 | }), | ||
| 160 | { status: 200 }, | ||
| 161 | ), | ||
| 162 | ) | ||
| 163 | .mockResolvedValueOnce( | ||
| 164 | new Response( | ||
| 165 | JSON.stringify({ | ||
| 166 | token: "ws-token", | ||
| 167 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 168 | }), | ||
| 169 | { status: 200 }, | ||
| 170 | ), | ||
| 171 | ); | ||
| 172 | globalThis.fetch = fetchMock as typeof fetch; | ||
| 173 | |||
| 174 | await expect( | ||
| 175 | fetchRemoteEnvironmentDescriptor({ | ||
| 176 | httpBaseUrl: "https://remote.example.com/", | ||
| 177 | }), | ||
| 178 | ).resolves.toMatchObject({ | ||
| 179 | environmentId: "environment-remote", | ||
| 180 | label: "Remote environment", | ||
| 181 | }); | ||
| 182 | |||
| 183 | await expect( | ||
| 184 | fetchRemoteSessionState({ | ||
| 185 | httpBaseUrl: "https://remote.example.com/", | ||
| 186 | bearerToken: "bearer-token", | ||
| 187 | }), | ||
| 188 | ).resolves.toMatchObject({ | ||
| 189 | authenticated: true, | ||
| 190 | role: "client", | ||
| 191 | }); | ||
| 192 | |||
| 193 | await expect( | ||
| 194 | issueRemoteWebSocketToken({ | ||
| 195 | httpBaseUrl: "https://remote.example.com/", | ||
| 196 | bearerToken: "bearer-token", | ||
| 197 | }), | ||
| 198 | ).resolves.toMatchObject({ | ||
| 199 | token: "ws-token", | ||
| 200 | }); | ||
| 201 | |||
| 202 | expect(fetchMock).toHaveBeenNthCalledWith( | ||
| 203 | 1, | ||
| 204 | "https://remote.example.com/.well-known/t3/environment", | ||
| 205 | { | ||
| 206 | method: "GET", | ||
| 207 | headers: {}, | ||
| 208 | }, | ||
| 209 | ); | ||
| 210 | expect(fetchMock).toHaveBeenNthCalledWith(2, "https://remote.example.com/api/auth/session", { | ||
| 211 | method: "GET", | ||
| 212 | headers: { | ||
| 213 | authorization: "Bearer bearer-token", | ||
| 214 | }, | ||
| 215 | }); | ||
| 216 | expect(fetchMock).toHaveBeenNthCalledWith(3, "https://remote.example.com/api/auth/ws-token", { | ||
| 217 | method: "POST", | ||
| 218 | headers: { | ||
| 219 | authorization: "Bearer bearer-token", | ||
| 220 | }, | ||
| 221 | }); | ||
| 222 | }); | ||
| 223 | |||
| 224 | it("mints a websocket url with a short-lived ws token", async () => { | ||
| 225 | const fetchMock = vi.fn().mockResolvedValue( | ||
| 226 | new Response( | ||
| 227 | JSON.stringify({ | ||
| 228 | token: "ws-token", | ||
| 229 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 230 | }), | ||
| 231 | { status: 200 }, | ||
| 232 | ), | ||
| 233 | ); | ||
| 234 | globalThis.fetch = fetchMock as typeof fetch; | ||
| 235 | |||
| 236 | await expect( | ||
| 237 | resolveRemoteWebSocketConnectionUrl({ | ||
| 238 | wsBaseUrl: "wss://remote.example.com/", | ||
| 239 | httpBaseUrl: "https://remote.example.com/", | ||
| 240 | bearerToken: "bearer-token", | ||
| 241 | }), | ||
| 242 | ).resolves.toBe("wss://remote.example.com/?wsToken=ws-token"); | ||
| 243 | }); | ||
| 244 | }); | ||
| 245 | |||
| 246 | afterEach(() => { | ||
| 247 | globalThis.fetch = originalFetch; | ||
| 248 | }); | ||
apps/web/src/environments/remote/api.ts deleted-149| ... | @@ -1,149 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | AuthBearerBootstrapResult, | ||
| 3 | AuthSessionState, | ||
| 4 | AuthWebSocketTokenResult, | ||
| 5 | ExecutionEnvironmentDescriptor, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | class RemoteEnvironmentAuthHttpError extends Error { | ||
| 9 | readonly status: number; | ||
| 10 | |||
| 11 | constructor(message: string, status: number) { | ||
| 12 | super(message); | ||
| 13 | this.name = "RemoteEnvironmentAuthHttpError"; | ||
| 14 | this.status = status; | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | export function isRemoteEnvironmentAuthHttpError( | ||
| 19 | error: unknown, | ||
| 20 | ): error is RemoteEnvironmentAuthHttpError { | ||
| 21 | return error instanceof RemoteEnvironmentAuthHttpError; | ||
| 22 | } | ||
| 23 | |||
| 24 | function remoteEndpointUrl(httpBaseUrl: string, pathname: string): string { | ||
| 25 | const url = new URL(httpBaseUrl); | ||
| 26 | url.pathname = pathname; | ||
| 27 | url.search = ""; | ||
| 28 | url.hash = ""; | ||
| 29 | return url.toString(); | ||
| 30 | } | ||
| 31 | |||
| 32 | async function readRemoteAuthErrorMessage( | ||
| 33 | response: Response, | ||
| 34 | fallbackMessage: string, | ||
| 35 | ): Promise<string> { | ||
| 36 | const text = await response.text(); | ||
| 37 | if (!text) { | ||
| 38 | return fallbackMessage; | ||
| 39 | } | ||
| 40 | |||
| 41 | try { | ||
| 42 | const parsed = JSON.parse(text) as { readonly error?: string }; | ||
| 43 | if (typeof parsed.error === "string" && parsed.error.length > 0) { | ||
| 44 | return parsed.error; | ||
| 45 | } | ||
| 46 | } catch { | ||
| 47 | // Fall back to raw text below. | ||
| 48 | } | ||
| 49 | |||
| 50 | return text; | ||
| 51 | } | ||
| 52 | |||
| 53 | async function fetchRemoteJson<T>(input: { | ||
| 54 | readonly httpBaseUrl: string; | ||
| 55 | readonly pathname: string; | ||
| 56 | readonly method?: "GET" | "POST"; | ||
| 57 | readonly bearerToken?: string; | ||
| 58 | readonly body?: unknown; | ||
| 59 | }): Promise<T> { | ||
| 60 | const requestUrl = remoteEndpointUrl(input.httpBaseUrl, input.pathname); | ||
| 61 | let response: Response; | ||
| 62 | try { | ||
| 63 | response = await fetch(requestUrl, { | ||
| 64 | method: input.method ?? "GET", | ||
| 65 | headers: { | ||
| 66 | ...(input.body !== undefined ? { "content-type": "application/json" } : {}), | ||
| 67 | ...(input.bearerToken ? { authorization: `Bearer ${input.bearerToken}` } : {}), | ||
| 68 | }, | ||
| 69 | ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}), | ||
| 70 | }); | ||
| 71 | } catch (error) { | ||
| 72 | throw new Error( | ||
| 73 | `Failed to fetch remote auth endpoint ${requestUrl} (${(error as Error).message}).`, | ||
| 74 | { cause: error }, | ||
| 75 | ); | ||
| 76 | } | ||
| 77 | |||
| 78 | if (!response.ok) { | ||
| 79 | throw new RemoteEnvironmentAuthHttpError( | ||
| 80 | await readRemoteAuthErrorMessage( | ||
| 81 | response, | ||
| 82 | `Remote auth request failed (${response.status}).`, | ||
| 83 | ), | ||
| 84 | response.status, | ||
| 85 | ); | ||
| 86 | } | ||
| 87 | |||
| 88 | return (await response.json()) as T; | ||
| 89 | } | ||
| 90 | |||
| 91 | export async function bootstrapRemoteBearerSession(input: { | ||
| 92 | readonly httpBaseUrl: string; | ||
| 93 | readonly credential: string; | ||
| 94 | }): Promise<AuthBearerBootstrapResult> { | ||
| 95 | return fetchRemoteJson<AuthBearerBootstrapResult>({ | ||
| 96 | httpBaseUrl: input.httpBaseUrl, | ||
| 97 | pathname: "/api/auth/bootstrap/bearer", | ||
| 98 | method: "POST", | ||
| 99 | body: { | ||
| 100 | credential: input.credential, | ||
| 101 | }, | ||
| 102 | }); | ||
| 103 | } | ||
| 104 | |||
| 105 | export async function fetchRemoteSessionState(input: { | ||
| 106 | readonly httpBaseUrl: string; | ||
| 107 | readonly bearerToken: string; | ||
| 108 | }): Promise<AuthSessionState> { | ||
| 109 | return fetchRemoteJson<AuthSessionState>({ | ||
| 110 | httpBaseUrl: input.httpBaseUrl, | ||
| 111 | pathname: "/api/auth/session", | ||
| 112 | bearerToken: input.bearerToken, | ||
| 113 | }); | ||
| 114 | } | ||
| 115 | |||
| 116 | export async function fetchRemoteEnvironmentDescriptor(input: { | ||
| 117 | readonly httpBaseUrl: string; | ||
| 118 | }): Promise<ExecutionEnvironmentDescriptor> { | ||
| 119 | return fetchRemoteJson<ExecutionEnvironmentDescriptor>({ | ||
| 120 | httpBaseUrl: input.httpBaseUrl, | ||
| 121 | pathname: "/.well-known/t3/environment", | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | |||
| 125 | export async function issueRemoteWebSocketToken(input: { | ||
| 126 | readonly httpBaseUrl: string; | ||
| 127 | readonly bearerToken: string; | ||
| 128 | }): Promise<AuthWebSocketTokenResult> { | ||
| 129 | return fetchRemoteJson<AuthWebSocketTokenResult>({ | ||
| 130 | httpBaseUrl: input.httpBaseUrl, | ||
| 131 | pathname: "/api/auth/ws-token", | ||
| 132 | method: "POST", | ||
| 133 | bearerToken: input.bearerToken, | ||
| 134 | }); | ||
| 135 | } | ||
| 136 | |||
| 137 | export async function resolveRemoteWebSocketConnectionUrl(input: { | ||
| 138 | readonly wsBaseUrl: string; | ||
| 139 | readonly httpBaseUrl: string; | ||
| 140 | readonly bearerToken: string; | ||
| 141 | }): Promise<string> { | ||
| 142 | const issued = await issueRemoteWebSocketToken({ | ||
| 143 | httpBaseUrl: input.httpBaseUrl, | ||
| 144 | bearerToken: input.bearerToken, | ||
| 145 | }); | ||
| 146 | const url = new URL(input.wsBaseUrl, window.location.origin); | ||
| 147 | url.searchParams.set("wsToken", issued.token); | ||
| 148 | return url.toString(); | ||
| 149 | } | ||
apps/web/src/environments/remote/target.ts deleted-97| ... | @@ -1,97 +0,0 @@ | ||
| 1 | import { getPairingTokenFromUrl } from "../../pairingUrl"; | ||
| 2 | import { readHostedPairingRequest } from "../../hostedPairing"; | ||
| 3 | |||
| 4 | export interface ResolvedRemotePairingTarget { | ||
| 5 | readonly credential: string; | ||
| 6 | readonly httpBaseUrl: string; | ||
| 7 | readonly wsBaseUrl: string; | ||
| 8 | } | ||
| 9 | |||
| 10 | function normalizeRemoteBaseUrl(rawValue: string): URL { | ||
| 11 | const trimmed = rawValue.trim(); | ||
| 12 | if (!trimmed) { | ||
| 13 | throw new Error("Enter a backend URL."); | ||
| 14 | } | ||
| 15 | |||
| 16 | const normalizedInput = | ||
| 17 | /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//") | ||
| 18 | ? trimmed | ||
| 19 | : `https://${trimmed}`; | ||
| 20 | const url = new URL(normalizedInput, window.location.origin); | ||
| 21 | url.pathname = "/"; | ||
| 22 | url.search = ""; | ||
| 23 | url.hash = ""; | ||
| 24 | return url; | ||
| 25 | } | ||
| 26 | |||
| 27 | function toHttpBaseUrl(url: URL): string { | ||
| 28 | const next = new URL(url.toString()); | ||
| 29 | if (next.protocol === "ws:") { | ||
| 30 | next.protocol = "http:"; | ||
| 31 | } else if (next.protocol === "wss:") { | ||
| 32 | next.protocol = "https:"; | ||
| 33 | } | ||
| 34 | next.pathname = "/"; | ||
| 35 | next.search = ""; | ||
| 36 | next.hash = ""; | ||
| 37 | return next.toString(); | ||
| 38 | } | ||
| 39 | |||
| 40 | function toWsBaseUrl(url: URL): string { | ||
| 41 | const next = new URL(url.toString()); | ||
| 42 | if (next.protocol === "http:") { | ||
| 43 | next.protocol = "ws:"; | ||
| 44 | } else if (next.protocol === "https:") { | ||
| 45 | next.protocol = "wss:"; | ||
| 46 | } | ||
| 47 | next.pathname = "/"; | ||
| 48 | next.search = ""; | ||
| 49 | next.hash = ""; | ||
| 50 | return next.toString(); | ||
| 51 | } | ||
| 52 | |||
| 53 | export function resolveRemotePairingTarget(input: { | ||
| 54 | readonly pairingUrl?: string; | ||
| 55 | readonly host?: string; | ||
| 56 | readonly pairingCode?: string; | ||
| 57 | }): ResolvedRemotePairingTarget { | ||
| 58 | const pairingUrl = input.pairingUrl?.trim() ?? ""; | ||
| 59 | if (pairingUrl.length > 0) { | ||
| 60 | const url = new URL(pairingUrl, window.location.origin); | ||
| 61 | const hostedPairingRequest = readHostedPairingRequest(url); | ||
| 62 | if (hostedPairingRequest) { | ||
| 63 | const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host); | ||
| 64 | return { | ||
| 65 | credential: hostedPairingRequest.token, | ||
| 66 | httpBaseUrl: toHttpBaseUrl(hostedBackendUrl), | ||
| 67 | wsBaseUrl: toWsBaseUrl(hostedBackendUrl), | ||
| 68 | }; | ||
| 69 | } | ||
| 70 | |||
| 71 | const credential = getPairingTokenFromUrl(url) ?? ""; | ||
| 72 | if (!credential) { | ||
| 73 | throw new Error("Pairing URL is missing its token."); | ||
| 74 | } | ||
| 75 | return { | ||
| 76 | credential, | ||
| 77 | httpBaseUrl: toHttpBaseUrl(url), | ||
| 78 | wsBaseUrl: toWsBaseUrl(url), | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | const host = input.host?.trim() ?? ""; | ||
| 83 | const pairingCode = input.pairingCode?.trim() ?? ""; | ||
| 84 | if (!host) { | ||
| 85 | throw new Error("Enter a backend URL."); | ||
| 86 | } | ||
| 87 | if (!pairingCode) { | ||
| 88 | throw new Error("Enter a pairing code."); | ||
| 89 | } | ||
| 90 | |||
| 91 | const normalizedHost = normalizeRemoteBaseUrl(host); | ||
| 92 | return { | ||
| 93 | credential: pairingCode, | ||
| 94 | httpBaseUrl: toHttpBaseUrl(normalizedHost), | ||
| 95 | wsBaseUrl: toWsBaseUrl(normalizedHost), | ||
| 96 | }; | ||
| 97 | } | ||
apps/web/src/environments/runtime/connection.test.ts+46-20| ... | @@ -2,12 +2,11 @@ import { EnvironmentId } from "@t3tools/contracts"; | ... | @@ -2,12 +2,11 @@ import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it, vi } from "vitest"; | 2 | import { describe, expect, it, vi } from "vitest"; |
| 3 | 3 | ||
| 4 | import { createEnvironmentConnection } from "./connection"; | 4 | import { createEnvironmentConnection } from "./connection"; |
| 5 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | 5 | import type { WsRpcClient } from "@t3tools/client-runtime"; |
| 6 | 6 | ||
| 7 | function createTestClient() { | 7 | function createTestClient(config?: { readonly emitInitialSnapshot?: boolean }) { |
| 8 | const lifecycleListeners = new Set<(event: any) => void>(); | 8 | const lifecycleListeners = new Set<(event: any) => void>(); |
| 9 | const configListeners = new Set<(event: any) => void>(); | 9 | const configListeners = new Set<(event: any) => void>(); |
| 10 | const terminalListeners = new Set<(event: any) => void>(); | ||
| 11 | const shellListeners = new Set<(event: any) => void>(); | 10 | const shellListeners = new Set<(event: any) => void>(); |
| 12 | let shellResubscribe: (() => void) | undefined; | 11 | let shellResubscribe: (() => void) | undefined; |
| 13 | 12 | ||
| ... | @@ -44,17 +43,19 @@ function createTestClient() { | ... | @@ -44,17 +43,19 @@ function createTestClient() { |
| 44 | (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { | 43 | (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { |
| 45 | shellListeners.add(listener); | 44 | shellListeners.add(listener); |
| 46 | shellResubscribe = options?.onResubscribe; | 45 | shellResubscribe = options?.onResubscribe; |
| 47 | queueMicrotask(() => { | 46 | if (config?.emitInitialSnapshot !== false) { |
| 48 | listener({ | 47 | queueMicrotask(() => { |
| 49 | kind: "snapshot", | 48 | listener({ |
| 50 | snapshot: { | 49 | kind: "snapshot", |
| 51 | snapshotSequence: 1, | 50 | snapshot: { |
| 52 | projects: [], | 51 | snapshotSequence: 1, |
| 53 | threads: [], | 52 | projects: [], |
| 54 | updatedAt: "2026-04-12T00:00:00.000Z", | 53 | threads: [], |
| 55 | }, | 54 | updatedAt: "2026-04-12T00:00:00.000Z", |
| 55 | }, | ||
| 56 | }); | ||
| 56 | }); | 57 | }); |
| 57 | }); | 58 | } |
| 58 | return () => { | 59 | return () => { |
| 59 | shellListeners.delete(listener); | 60 | shellListeners.delete(listener); |
| 60 | if (shellResubscribe === options?.onResubscribe) { | 61 | if (shellResubscribe === options?.onResubscribe) { |
| ... | @@ -67,15 +68,14 @@ function createTestClient() { | ... | @@ -67,15 +68,14 @@ function createTestClient() { |
| 67 | }, | 68 | }, |
| 68 | terminal: { | 69 | terminal: { |
| 69 | open: vi.fn(async () => undefined), | 70 | open: vi.fn(async () => undefined), |
| 71 | attach: vi.fn(() => () => undefined), | ||
| 70 | write: vi.fn(async () => undefined), | 72 | write: vi.fn(async () => undefined), |
| 71 | resize: vi.fn(async () => undefined), | 73 | resize: vi.fn(async () => undefined), |
| 72 | clear: vi.fn(async () => undefined), | 74 | clear: vi.fn(async () => undefined), |
| 73 | restart: vi.fn(async () => undefined), | 75 | restart: vi.fn(async () => undefined), |
| 74 | close: vi.fn(async () => undefined), | 76 | close: vi.fn(async () => undefined), |
| 75 | onEvent: (listener: (event: any) => void) => { | 77 | onEvent: vi.fn(() => () => undefined), |
| 76 | terminalListeners.add(listener); | 78 | onMetadata: vi.fn(() => () => undefined), |
| 77 | return () => terminalListeners.delete(listener); | ||
| 78 | }, | ||
| 79 | }, | 79 | }, |
| 80 | projects: { | 80 | projects: { |
| 81 | searchEntries: vi.fn(async () => []), | 81 | searchEntries: vi.fn(async () => []), |
| ... | @@ -89,6 +89,9 @@ function createTestClient() { | ... | @@ -89,6 +89,9 @@ function createTestClient() { |
| 89 | resolvePullRequest: vi.fn(async () => undefined), | 89 | resolvePullRequest: vi.fn(async () => undefined), |
| 90 | preparePullRequestThread: vi.fn(async () => undefined), | 90 | preparePullRequestThread: vi.fn(async () => undefined), |
| 91 | }, | 91 | }, |
| 92 | review: { | ||
| 93 | getDiffPreview: vi.fn(async () => undefined), | ||
| 94 | }, | ||
| 92 | } as unknown as WsRpcClient; | 95 | } as unknown as WsRpcClient; |
| 93 | 96 | ||
| 94 | return { | 97 | return { |
| ... | @@ -154,7 +157,6 @@ describe("createEnvironmentConnection", () => { | ... | @@ -154,7 +157,6 @@ describe("createEnvironmentConnection", () => { |
| 154 | client, | 157 | client, |
| 155 | applyShellEvent: vi.fn(), | 158 | applyShellEvent: vi.fn(), |
| 156 | syncShellSnapshot, | 159 | syncShellSnapshot, |
| 157 | applyTerminalEvent: vi.fn(), | ||
| 158 | }); | 160 | }); |
| 159 | 161 | ||
| 160 | await connection.ensureBootstrapped(); | 162 | await connection.ensureBootstrapped(); |
| ... | @@ -186,7 +188,6 @@ describe("createEnvironmentConnection", () => { | ... | @@ -186,7 +188,6 @@ describe("createEnvironmentConnection", () => { |
| 186 | client, | 188 | client, |
| 187 | applyShellEvent: vi.fn(), | 189 | applyShellEvent: vi.fn(), |
| 188 | syncShellSnapshot: vi.fn(), | 190 | syncShellSnapshot: vi.fn(), |
| 189 | applyTerminalEvent: vi.fn(), | ||
| 190 | }); | 191 | }); |
| 191 | 192 | ||
| 192 | expect(() => emitWelcome(EnvironmentId.make("env-2"))).toThrow( | 193 | expect(() => emitWelcome(EnvironmentId.make("env-2"))).toThrow( |
| ... | @@ -216,7 +217,6 @@ describe("createEnvironmentConnection", () => { | ... | @@ -216,7 +217,6 @@ describe("createEnvironmentConnection", () => { |
| 216 | client, | 217 | client, |
| 217 | applyShellEvent: vi.fn(), | 218 | applyShellEvent: vi.fn(), |
| 218 | syncShellSnapshot, | 219 | syncShellSnapshot, |
| 219 | applyTerminalEvent: vi.fn(), | ||
| 220 | }); | 220 | }); |
| 221 | 221 | ||
| 222 | await connection.ensureBootstrapped(); | 222 | await connection.ensureBootstrapped(); |
| ... | @@ -266,4 +266,30 @@ describe("createEnvironmentConnection", () => { | ... | @@ -266,4 +266,30 @@ describe("createEnvironmentConnection", () => { |
| 266 | 266 | ||
| 267 | await connection.dispose(); | 267 | await connection.dispose(); |
| 268 | }); | 268 | }); |
| 269 | |||
| 270 | it("rejects bootstrap waits when a pending connection is disposed", async () => { | ||
| 271 | const environmentId = EnvironmentId.make("env-1"); | ||
| 272 | const { client } = createTestClient({ emitInitialSnapshot: false }); | ||
| 273 | const connection = createEnvironmentConnection({ | ||
| 274 | kind: "saved", | ||
| 275 | knownEnvironment: { | ||
| 276 | id: "env-1", | ||
| 277 | label: "Remote env", | ||
| 278 | source: "manual", | ||
| 279 | target: { | ||
| 280 | httpBaseUrl: "http://example.test", | ||
| 281 | wsBaseUrl: "ws://example.test", | ||
| 282 | }, | ||
| 283 | environmentId, | ||
| 284 | }, | ||
| 285 | client, | ||
| 286 | applyShellEvent: vi.fn(), | ||
| 287 | syncShellSnapshot: vi.fn(), | ||
| 288 | }); | ||
| 289 | const pendingBootstrap = connection.ensureBootstrapped(); | ||
| 290 | |||
| 291 | await connection.dispose(); | ||
| 292 | |||
| 293 | await expect(pendingBootstrap).rejects.toThrow("was disposed before it finished bootstrapping"); | ||
| 294 | }); | ||
| 269 | }); | 295 | }); |
apps/web/src/environments/runtime/connection.ts+7-182| ... | @@ -1,182 +1,7 @@ | ... | @@ -1,182 +1,7 @@ |
| 1 | import type { | 1 | export { |
| 2 | EnvironmentId, | 2 | createEnvironmentConnection, |
| 3 | OrchestrationShellSnapshot, | 3 | createEnvironmentConnectionAttemptRegistry, |
| 4 | OrchestrationShellStreamEvent, | 4 | EnvironmentConnectionAttemptCancelledError, |
| 5 | ServerConfig, | 5 | EnvironmentConnectionDisposedError, |
| 6 | ServerLifecycleWelcomePayload, | 6 | type EnvironmentConnection, |
| 7 | TerminalEvent, | 7 | } from "@t3tools/client-runtime"; |
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import type { KnownEnvironment } from "@t3tools/client-runtime"; | ||
| 10 | |||
| 11 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | ||
| 12 | |||
| 13 | export interface EnvironmentConnection { | ||
| 14 | readonly kind: "primary" | "saved"; | ||
| 15 | readonly environmentId: EnvironmentId; | ||
| 16 | readonly knownEnvironment: KnownEnvironment; | ||
| 17 | readonly client: WsRpcClient; | ||
| 18 | readonly ensureBootstrapped: () => Promise<void>; | ||
| 19 | readonly reconnect: () => Promise<void>; | ||
| 20 | readonly dispose: () => Promise<void>; | ||
| 21 | } | ||
| 22 | |||
| 23 | interface OrchestrationHandlers { | ||
| 24 | readonly applyShellEvent: ( | ||
| 25 | event: OrchestrationShellStreamEvent, | ||
| 26 | environmentId: EnvironmentId, | ||
| 27 | ) => void; | ||
| 28 | readonly syncShellSnapshot: ( | ||
| 29 | snapshot: OrchestrationShellSnapshot, | ||
| 30 | environmentId: EnvironmentId, | ||
| 31 | ) => void; | ||
| 32 | readonly applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => void; | ||
| 33 | } | ||
| 34 | |||
| 35 | interface EnvironmentConnectionInput extends OrchestrationHandlers { | ||
| 36 | readonly kind: "primary" | "saved"; | ||
| 37 | readonly knownEnvironment: KnownEnvironment; | ||
| 38 | readonly client: WsRpcClient; | ||
| 39 | readonly refreshMetadata?: () => Promise<void>; | ||
| 40 | readonly onConfigSnapshot?: (config: ServerConfig) => void; | ||
| 41 | readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; | ||
| 42 | } | ||
| 43 | |||
| 44 | function createBootstrapGate() { | ||
| 45 | let resolve: (() => void) | null = null; | ||
| 46 | let reject: ((error: unknown) => void) | null = null; | ||
| 47 | let promise = new Promise<void>((nextResolve, nextReject) => { | ||
| 48 | resolve = nextResolve; | ||
| 49 | reject = nextReject; | ||
| 50 | }); | ||
| 51 | |||
| 52 | return { | ||
| 53 | wait: () => promise, | ||
| 54 | resolve: () => { | ||
| 55 | resolve?.(); | ||
| 56 | resolve = null; | ||
| 57 | reject = null; | ||
| 58 | }, | ||
| 59 | reject: (error: unknown) => { | ||
| 60 | reject?.(error); | ||
| 61 | resolve = null; | ||
| 62 | reject = null; | ||
| 63 | }, | ||
| 64 | reset: () => { | ||
| 65 | promise = new Promise<void>((nextResolve, nextReject) => { | ||
| 66 | resolve = nextResolve; | ||
| 67 | reject = nextReject; | ||
| 68 | }); | ||
| 69 | }, | ||
| 70 | }; | ||
| 71 | } | ||
| 72 | |||
| 73 | export function createEnvironmentConnection( | ||
| 74 | input: EnvironmentConnectionInput, | ||
| 75 | ): EnvironmentConnection { | ||
| 76 | const environmentId = input.knownEnvironment.environmentId; | ||
| 77 | |||
| 78 | if (!environmentId) { | ||
| 79 | throw new Error( | ||
| 80 | `Known environment ${input.knownEnvironment.label} is missing its environmentId.`, | ||
| 81 | ); | ||
| 82 | } | ||
| 83 | |||
| 84 | let disposed = false; | ||
| 85 | const bootstrapGate = createBootstrapGate(); | ||
| 86 | const shouldObserveLifecycle = input.kind === "saved" || input.onWelcome !== undefined; | ||
| 87 | const shouldObserveConfig = input.kind === "saved" || input.onConfigSnapshot !== undefined; | ||
| 88 | |||
| 89 | const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { | ||
| 90 | if (environmentId !== nextEnvironmentId) { | ||
| 91 | throw new Error( | ||
| 92 | `Environment connection ${environmentId} changed identity to ${nextEnvironmentId} via ${source}.`, | ||
| 93 | ); | ||
| 94 | } | ||
| 95 | }; | ||
| 96 | |||
| 97 | const unsubLifecycle = shouldObserveLifecycle | ||
| 98 | ? input.client.server.subscribeLifecycle( | ||
| 99 | (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => { | ||
| 100 | if (event.type !== "welcome") { | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | observeEnvironmentIdentity( | ||
| 104 | event.payload.environment.environmentId, | ||
| 105 | "server lifecycle welcome", | ||
| 106 | ); | ||
| 107 | input.onWelcome?.(event.payload); | ||
| 108 | }, | ||
| 109 | ) | ||
| 110 | : () => undefined; | ||
| 111 | |||
| 112 | const unsubConfig = shouldObserveConfig | ||
| 113 | ? input.client.server.subscribeConfig( | ||
| 114 | (event: Parameters<Parameters<WsRpcClient["server"]["subscribeConfig"]>[0]>[0]) => { | ||
| 115 | if (event.type !== "snapshot") { | ||
| 116 | return; | ||
| 117 | } | ||
| 118 | observeEnvironmentIdentity( | ||
| 119 | event.config.environment.environmentId, | ||
| 120 | "server config snapshot", | ||
| 121 | ); | ||
| 122 | input.onConfigSnapshot?.(event.config); | ||
| 123 | }, | ||
| 124 | ) | ||
| 125 | : () => undefined; | ||
| 126 | |||
| 127 | const unsubShell = input.client.orchestration.subscribeShell( | ||
| 128 | (item: Parameters<Parameters<WsRpcClient["orchestration"]["subscribeShell"]>[0]>[0]) => { | ||
| 129 | if (item.kind === "snapshot") { | ||
| 130 | input.syncShellSnapshot(item.snapshot, environmentId); | ||
| 131 | bootstrapGate.resolve(); | ||
| 132 | return; | ||
| 133 | } | ||
| 134 | input.applyShellEvent(item, environmentId); | ||
| 135 | }, | ||
| 136 | { | ||
| 137 | onResubscribe: () => { | ||
| 138 | if (disposed) { | ||
| 139 | return; | ||
| 140 | } | ||
| 141 | bootstrapGate.reset(); | ||
| 142 | }, | ||
| 143 | }, | ||
| 144 | ); | ||
| 145 | |||
| 146 | const unsubTerminalEvent = input.client.terminal.onEvent( | ||
| 147 | (event: Parameters<Parameters<WsRpcClient["terminal"]["onEvent"]>[0]>[0]) => { | ||
| 148 | input.applyTerminalEvent(event, environmentId); | ||
| 149 | }, | ||
| 150 | ); | ||
| 151 | |||
| 152 | const cleanup = () => { | ||
| 153 | disposed = true; | ||
| 154 | unsubShell(); | ||
| 155 | unsubTerminalEvent(); | ||
| 156 | unsubLifecycle(); | ||
| 157 | unsubConfig(); | ||
| 158 | }; | ||
| 159 | |||
| 160 | return { | ||
| 161 | kind: input.kind, | ||
| 162 | environmentId, | ||
| 163 | knownEnvironment: input.knownEnvironment, | ||
| 164 | client: input.client, | ||
| 165 | ensureBootstrapped: () => bootstrapGate.wait(), | ||
| 166 | reconnect: async () => { | ||
| 167 | bootstrapGate.reset(); | ||
| 168 | try { | ||
| 169 | await input.client.reconnect(); | ||
| 170 | await input.refreshMetadata?.(); | ||
| 171 | await bootstrapGate.wait(); | ||
| 172 | } catch (error) { | ||
| 173 | bootstrapGate.reject(error); | ||
| 174 | throw error; | ||
| 175 | } | ||
| 176 | }, | ||
| 177 | dispose: async () => { | ||
| 178 | cleanup(); | ||
| 179 | await input.client.dispose(); | ||
| 180 | }, | ||
| 181 | }; | ||
| 182 | } |
apps/web/src/environments/runtime/index.ts+1| ... | @@ -27,4 +27,5 @@ export { | ... | @@ -27,4 +27,5 @@ export { |
| 27 | resetEnvironmentServiceForTests, | 27 | resetEnvironmentServiceForTests, |
| 28 | startEnvironmentConnectionService, | 28 | startEnvironmentConnectionService, |
| 29 | subscribeEnvironmentConnections, | 29 | subscribeEnvironmentConnections, |
| 30 | subscribeProviderInvalidations, | ||
| 30 | } from "./service"; | 31 | } from "./service"; |
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+36-19| ... | @@ -9,6 +9,7 @@ const mockBootstrapRemoteBearerSession = vi.fn(); | ... | @@ -9,6 +9,7 @@ const mockBootstrapRemoteBearerSession = vi.fn(); |
| 9 | const mockFetchRemoteSessionState = vi.fn(); | 9 | const mockFetchRemoteSessionState = vi.fn(); |
| 10 | const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false); | 10 | const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false); |
| 11 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); | 11 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); |
| 12 | const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect); | ||
| 12 | const mockBootstrapSshBearerSession = vi.fn(); | 13 | const mockBootstrapSshBearerSession = vi.fn(); |
| 13 | const mockFetchSshSessionState = vi.fn(); | 14 | const mockFetchSshSessionState = vi.fn(); |
| 14 | const mockPersistSavedEnvironmentRecord = vi.fn(); | 15 | const mockPersistSavedEnvironmentRecord = vi.fn(); |
| ... | @@ -56,16 +57,15 @@ const mockClientGetConfig = vi.fn(async () => ({ | ... | @@ -56,16 +57,15 @@ const mockClientGetConfig = vi.fn(async () => ({ |
| 56 | }, | 57 | }, |
| 57 | })); | 58 | })); |
| 58 | 59 | ||
| 59 | vi.mock("../remote/target", () => ({ | 60 | vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ |
| 61 | ...(await importOriginal<typeof import("@t3tools/shared/remote")>()), | ||
| 60 | resolveRemotePairingTarget: mockResolveRemotePairingTarget, | 62 | resolveRemotePairingTarget: mockResolveRemotePairingTarget, |
| 61 | })); | 63 | })); |
| 62 | 64 | ||
| 63 | vi.mock("../remote/api", () => ({ | 65 | vi.mock("../../lib/runtime", () => ({ |
| 64 | bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, | 66 | remoteHttpRuntime: { |
| 65 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, | 67 | runPromise: mockRemoteHttpRunPromise, |
| 66 | fetchRemoteSessionState: mockFetchRemoteSessionState, | 68 | }, |
| 67 | isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError, | ||
| 68 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | ||
| 69 | })); | 69 | })); |
| 70 | 70 | ||
| 71 | vi.mock("~/localApi", () => ({ | 71 | vi.mock("~/localApi", () => ({ |
| ... | @@ -105,20 +105,33 @@ vi.mock("./catalog", () => ({ | ... | @@ -105,20 +105,33 @@ vi.mock("./catalog", () => ({ |
| 105 | writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, | 105 | writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, |
| 106 | })); | 106 | })); |
| 107 | 107 | ||
| 108 | vi.mock("./connection", () => ({ | 108 | vi.mock("./connection", async (importOriginal) => ({ |
| 109 | ...(await importOriginal<typeof import("./connection")>()), | ||
| 109 | createEnvironmentConnection: mockCreateEnvironmentConnection, | 110 | createEnvironmentConnection: mockCreateEnvironmentConnection, |
| 110 | })); | 111 | })); |
| 111 | 112 | ||
| 112 | vi.mock("../../rpc/wsRpcClient", () => ({ | 113 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 113 | createWsRpcClient: vi.fn(() => ({ | 114 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); |
| 114 | server: { | 115 | return { |
| 115 | getConfig: mockClientGetConfig, | 116 | ...actual, |
| 116 | }, | 117 | bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, |
| 117 | orchestration: { | 118 | createWsRpcClient: vi.fn(() => ({ |
| 118 | subscribeThread: vi.fn(() => () => {}), | 119 | server: { |
| 119 | }, | 120 | getConfig: mockClientGetConfig, |
| 120 | })), | 121 | }, |
| 121 | })); | 122 | terminal: { |
| 123 | onMetadata: vi.fn(() => () => undefined), | ||
| 124 | }, | ||
| 125 | orchestration: { | ||
| 126 | subscribeThread: vi.fn(() => () => {}), | ||
| 127 | }, | ||
| 128 | })), | ||
| 129 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, | ||
| 130 | fetchRemoteSessionState: mockFetchRemoteSessionState, | ||
| 131 | isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError, | ||
| 132 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | ||
| 133 | }; | ||
| 134 | }); | ||
| 122 | 135 | ||
| 123 | vi.mock("../../rpc/wsTransport", () => ({ | 136 | vi.mock("../../rpc/wsTransport", () => ({ |
| 124 | WsTransport: vi.fn(), | 137 | WsTransport: vi.fn(), |
| ... | @@ -501,7 +514,11 @@ describe("addSavedEnvironment", () => { | ... | @@ -501,7 +514,11 @@ describe("addSavedEnvironment", () => { |
| 501 | knownEnvironment: { | 514 | knownEnvironment: { |
| 502 | environmentId: EnvironmentId.make("environment-1"), | 515 | environmentId: EnvironmentId.make("environment-1"), |
| 503 | }, | 516 | }, |
| 504 | client: {}, | 517 | client: { |
| 518 | terminal: { | ||
| 519 | onMetadata: vi.fn(() => () => undefined), | ||
| 520 | }, | ||
| 521 | }, | ||
| 505 | ensureBootstrapped: async () => undefined, | 522 | ensureBootstrapped: async () => undefined, |
| 506 | reconnect: vi.fn(async () => { | 523 | reconnect: vi.fn(async () => { |
| 507 | throw new Error("socket closed"); | 524 | throw new Error("socket closed"); |
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+18-22| ... | @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ... | @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 5 | const mockCreateEnvironmentConnection = vi.fn(); | 5 | const mockCreateEnvironmentConnection = vi.fn(); |
| 6 | const mockCreateWsRpcClient = vi.fn(); | 6 | const mockCreateWsRpcClient = vi.fn(); |
| 7 | const mockFetchRemoteSessionState = vi.fn(); | 7 | const mockFetchRemoteSessionState = vi.fn(); |
| 8 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(() => "ws://remote.example.test"); | ||
| 9 | const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect); | ||
| 8 | const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); | 10 | const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); |
| 9 | const mockListSavedEnvironmentRecords = vi.fn(); | 11 | const mockListSavedEnvironmentRecords = vi.fn(); |
| 10 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); | 12 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); |
| ... | @@ -28,11 +30,10 @@ vi.mock("../primary", () => ({ | ... | @@ -28,11 +30,10 @@ vi.mock("../primary", () => ({ |
| 28 | })), | 30 | })), |
| 29 | })); | 31 | })); |
| 30 | 32 | ||
| 31 | vi.mock("../remote/api", () => ({ | 33 | vi.mock("../../lib/runtime", () => ({ |
| 32 | bootstrapRemoteBearerSession: vi.fn(), | 34 | remoteHttpRuntime: { |
| 33 | fetchRemoteEnvironmentDescriptor: vi.fn(), | 35 | runPromise: mockRemoteHttpRunPromise, |
| 34 | fetchRemoteSessionState: mockFetchRemoteSessionState, | 36 | }, |
| 35 | resolveRemoteWebSocketConnectionUrl: vi.fn(() => "ws://remote.example.test"), | ||
| 36 | })); | 37 | })); |
| 37 | 38 | ||
| 38 | vi.mock("./catalog", () => ({ | 39 | vi.mock("./catalog", () => ({ |
| ... | @@ -62,13 +63,20 @@ vi.mock("./catalog", () => ({ | ... | @@ -62,13 +63,20 @@ vi.mock("./catalog", () => ({ |
| 62 | writeSavedEnvironmentBearerToken: vi.fn(), | 63 | writeSavedEnvironmentBearerToken: vi.fn(), |
| 63 | })); | 64 | })); |
| 64 | 65 | ||
| 65 | vi.mock("./connection", () => ({ | 66 | vi.mock("./connection", async (importOriginal) => ({ |
| 67 | ...(await importOriginal<typeof import("./connection")>()), | ||
| 66 | createEnvironmentConnection: mockCreateEnvironmentConnection, | 68 | createEnvironmentConnection: mockCreateEnvironmentConnection, |
| 67 | })); | 69 | })); |
| 68 | 70 | ||
| 69 | vi.mock("../../rpc/wsRpcClient", () => ({ | 71 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 70 | createWsRpcClient: mockCreateWsRpcClient, | 72 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); |
| 71 | })); | 73 | return { |
| 74 | ...actual, | ||
| 75 | createWsRpcClient: mockCreateWsRpcClient, | ||
| 76 | fetchRemoteSessionState: mockFetchRemoteSessionState, | ||
| 77 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | ||
| 78 | }; | ||
| 79 | }); | ||
| 72 | 80 | ||
| 73 | vi.mock("../../rpc/wsTransport", () => ({ | 81 | vi.mock("../../rpc/wsTransport", () => ({ |
| 74 | WsTransport: MockWsTransport, | 82 | WsTransport: MockWsTransport, |
| ... | @@ -104,18 +112,6 @@ vi.mock("~/orchestrationEventEffects", () => ({ | ... | @@ -104,18 +112,6 @@ vi.mock("~/orchestrationEventEffects", () => ({ |
| 104 | })), | 112 | })), |
| 105 | })); | 113 | })); |
| 106 | 114 | ||
| 107 | vi.mock("~/lib/projectReactQuery", () => ({ | ||
| 108 | projectQueryKeys: { | ||
| 109 | all: ["projects"], | ||
| 110 | }, | ||
| 111 | })); | ||
| 112 | |||
| 113 | vi.mock("~/lib/providerReactQuery", () => ({ | ||
| 114 | providerQueryKeys: { | ||
| 115 | all: ["providers"], | ||
| 116 | }, | ||
| 117 | })); | ||
| 118 | |||
| 119 | vi.mock("~/store", () => ({ | 115 | vi.mock("~/store", () => ({ |
| 120 | useStore: { | 116 | useStore: { |
| 121 | getState: () => ({ | 117 | getState: () => ({ |
| ... | @@ -192,7 +188,7 @@ function createClient() { | ... | @@ -192,7 +188,7 @@ function createClient() { |
| 192 | clear: vi.fn(async () => undefined), | 188 | clear: vi.fn(async () => undefined), |
| 193 | restart: vi.fn(async () => undefined), | 189 | restart: vi.fn(async () => undefined), |
| 194 | close: vi.fn(async () => undefined), | 190 | close: vi.fn(async () => undefined), |
| 195 | onEvent: vi.fn(() => () => undefined), | 191 | onMetadata: vi.fn(() => () => undefined), |
| 196 | }, | 192 | }, |
| 197 | projects: { | 193 | projects: { |
| 198 | searchEntries: vi.fn(async () => []), | 194 | searchEntries: vi.fn(async () => []), |
apps/web/src/environments/runtime/service.test.ts deleted-148| ... | @@ -1,148 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | shouldApplyProjectionEvent, | ||
| 5 | shouldApplyProjectionSnapshot, | ||
| 6 | shouldApplyTerminalEvent, | ||
| 7 | } from "./service"; | ||
| 8 | |||
| 9 | describe("shouldApplyTerminalEvent", () => { | ||
| 10 | it("applies terminal events for draft-only threads", () => { | ||
| 11 | expect( | ||
| 12 | shouldApplyTerminalEvent({ | ||
| 13 | serverThreadArchivedAt: undefined, | ||
| 14 | hasDraftThread: true, | ||
| 15 | }), | ||
| 16 | ).toBe(true); | ||
| 17 | }); | ||
| 18 | |||
| 19 | it("drops terminal events for unknown threads", () => { | ||
| 20 | expect( | ||
| 21 | shouldApplyTerminalEvent({ | ||
| 22 | serverThreadArchivedAt: undefined, | ||
| 23 | hasDraftThread: false, | ||
| 24 | }), | ||
| 25 | ).toBe(false); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("drops terminal events for archived server threads even if a draft exists", () => { | ||
| 29 | expect( | ||
| 30 | shouldApplyTerminalEvent({ | ||
| 31 | serverThreadArchivedAt: "2026-04-09T00:00:00.000Z", | ||
| 32 | hasDraftThread: true, | ||
| 33 | }), | ||
| 34 | ).toBe(false); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it("applies terminal events for active server threads", () => { | ||
| 38 | expect( | ||
| 39 | shouldApplyTerminalEvent({ | ||
| 40 | serverThreadArchivedAt: null, | ||
| 41 | hasDraftThread: false, | ||
| 42 | }), | ||
| 43 | ).toBe(true); | ||
| 44 | }); | ||
| 45 | }); | ||
| 46 | |||
| 47 | describe("shouldApplyProjectionSnapshot", () => { | ||
| 48 | it("accepts the first snapshot for an environment", () => { | ||
| 49 | expect( | ||
| 50 | shouldApplyProjectionSnapshot({ | ||
| 51 | current: null, | ||
| 52 | next: { | ||
| 53 | snapshotSequence: 1, | ||
| 54 | updatedAt: "2026-04-22T10:00:00.000Z", | ||
| 55 | }, | ||
| 56 | }), | ||
| 57 | ).toBe(true); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("drops snapshots with an older sequence", () => { | ||
| 61 | expect( | ||
| 62 | shouldApplyProjectionSnapshot({ | ||
| 63 | current: { | ||
| 64 | sequence: 5, | ||
| 65 | updatedAt: "2026-04-22T10:05:00.000Z", | ||
| 66 | }, | ||
| 67 | next: { | ||
| 68 | snapshotSequence: 4, | ||
| 69 | updatedAt: "2026-04-22T10:06:00.000Z", | ||
| 70 | }, | ||
| 71 | }), | ||
| 72 | ).toBe(false); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("drops snapshots with the same sequence and older timestamp", () => { | ||
| 76 | expect( | ||
| 77 | shouldApplyProjectionSnapshot({ | ||
| 78 | current: { | ||
| 79 | sequence: 5, | ||
| 80 | updatedAt: "2026-04-22T10:05:00.000Z", | ||
| 81 | }, | ||
| 82 | next: { | ||
| 83 | snapshotSequence: 5, | ||
| 84 | updatedAt: "2026-04-22T10:04:59.000Z", | ||
| 85 | }, | ||
| 86 | }), | ||
| 87 | ).toBe(false); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("accepts snapshots with the same sequence and a newer timestamp", () => { | ||
| 91 | expect( | ||
| 92 | shouldApplyProjectionSnapshot({ | ||
| 93 | current: { | ||
| 94 | sequence: 5, | ||
| 95 | updatedAt: "2026-04-22T10:05:00.000Z", | ||
| 96 | }, | ||
| 97 | next: { | ||
| 98 | snapshotSequence: 5, | ||
| 99 | updatedAt: "2026-04-22T10:05:01.000Z", | ||
| 100 | }, | ||
| 101 | }), | ||
| 102 | ).toBe(true); | ||
| 103 | }); | ||
| 104 | }); | ||
| 105 | |||
| 106 | describe("shouldApplyProjectionEvent", () => { | ||
| 107 | it("accepts the first event for an environment", () => { | ||
| 108 | expect( | ||
| 109 | shouldApplyProjectionEvent({ | ||
| 110 | current: null, | ||
| 111 | sequence: 1, | ||
| 112 | }), | ||
| 113 | ).toBe(true); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("drops stale or duplicate events", () => { | ||
| 117 | expect( | ||
| 118 | shouldApplyProjectionEvent({ | ||
| 119 | current: { | ||
| 120 | sequence: 5, | ||
| 121 | updatedAt: "2026-04-22T10:05:00.000Z", | ||
| 122 | }, | ||
| 123 | sequence: 5, | ||
| 124 | }), | ||
| 125 | ).toBe(false); | ||
| 126 | expect( | ||
| 127 | shouldApplyProjectionEvent({ | ||
| 128 | current: { | ||
| 129 | sequence: 5, | ||
| 130 | updatedAt: "2026-04-22T10:05:00.000Z", | ||
| 131 | }, | ||
| 132 | sequence: 4, | ||
| 133 | }), | ||
| 134 | ).toBe(false); | ||
| 135 | }); | ||
| 136 | |||
| 137 | it("accepts newer events", () => { | ||
| 138 | expect( | ||
| 139 | shouldApplyProjectionEvent({ | ||
| 140 | current: { | ||
| 141 | sequence: 5, | ||
| 142 | updatedAt: "2026-04-22T10:05:00.000Z", | ||
| 143 | }, | ||
| 144 | sequence: 6, | ||
| 145 | }), | ||
| 146 | ).toBe(true); | ||
| 147 | }); | ||
| 148 | }); | ||
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+136-12| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { QueryClient } from "@tanstack/react-query"; | 1 | import { QueryClient } from "@tanstack/react-query"; |
| 2 | import type { WsRpcClient } from "@t3tools/client-runtime"; | ||
| 2 | import { | 3 | import { |
| 3 | EnvironmentId, | 4 | EnvironmentId, |
| 4 | ProjectId, | 5 | ProjectId, |
| ... | @@ -20,6 +21,8 @@ const mockReadSavedEnvironmentBearerToken = vi.fn(); | ... | @@ -20,6 +21,8 @@ const mockReadSavedEnvironmentBearerToken = vi.fn(); |
| 20 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); | 21 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); |
| 21 | const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); | 22 | const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); |
| 22 | const mockFetchRemoteSessionState = vi.fn(); | 23 | const mockFetchRemoteSessionState = vi.fn(); |
| 24 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/ws"); | ||
| 25 | const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect); | ||
| 23 | const mockConnectionReconnects: Array<ReturnType<typeof vi.fn>> = []; | 26 | const mockConnectionReconnects: Array<ReturnType<typeof vi.fn>> = []; |
| 24 | let savedEnvironmentRegistryListener: (() => void) | null = null; | 27 | let savedEnvironmentRegistryListener: (() => void) | null = null; |
| 25 | 28 | ||
| ... | @@ -31,12 +34,10 @@ vi.mock("../primary", () => ({ | ... | @@ -31,12 +34,10 @@ vi.mock("../primary", () => ({ |
| 31 | getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment, | 34 | getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment, |
| 32 | })); | 35 | })); |
| 33 | 36 | ||
| 34 | vi.mock("../remote/api", () => ({ | 37 | vi.mock("../../lib/runtime", () => ({ |
| 35 | bootstrapRemoteBearerSession: vi.fn(), | 38 | remoteHttpRuntime: { |
| 36 | fetchRemoteEnvironmentDescriptor: vi.fn(), | 39 | runPromise: mockRemoteHttpRunPromise, |
| 37 | fetchRemoteSessionState: mockFetchRemoteSessionState, | 40 | }, |
| 38 | isRemoteEnvironmentAuthHttpError: vi.fn(() => false), | ||
| 39 | resolveRemoteWebSocketConnectionUrl: vi.fn(async () => "ws://remote.example.test/ws"), | ||
| 40 | })); | 41 | })); |
| 41 | 42 | ||
| 42 | vi.mock("./catalog", () => ({ | 43 | vi.mock("./catalog", () => ({ |
| ... | @@ -66,13 +67,96 @@ vi.mock("./catalog", () => ({ | ... | @@ -66,13 +67,96 @@ vi.mock("./catalog", () => ({ |
| 66 | writeSavedEnvironmentBearerToken: vi.fn(), | 67 | writeSavedEnvironmentBearerToken: vi.fn(), |
| 67 | })); | 68 | })); |
| 68 | 69 | ||
| 69 | vi.mock("./connection", () => ({ | 70 | vi.mock("./connection", async (importOriginal) => ({ |
| 71 | ...(await importOriginal<typeof import("./connection")>()), | ||
| 70 | createEnvironmentConnection: mockCreateEnvironmentConnection, | 72 | createEnvironmentConnection: mockCreateEnvironmentConnection, |
| 71 | })); | 73 | })); |
| 72 | 74 | ||
| 73 | vi.mock("../../rpc/wsRpcClient", () => ({ | 75 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 74 | createWsRpcClient: mockCreateWsRpcClient, | 76 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); |
| 75 | })); | 77 | const stubWsClient: WsRpcClient = { |
| 78 | dispose: async () => undefined, | ||
| 79 | reconnect: async () => undefined, | ||
| 80 | isHeartbeatFresh: () => false, | ||
| 81 | orchestration: { | ||
| 82 | dispatchCommand: vi.fn(), | ||
| 83 | getTurnDiff: vi.fn(), | ||
| 84 | getFullThreadDiff: vi.fn(), | ||
| 85 | getArchivedShellSnapshot: vi.fn(), | ||
| 86 | subscribeShell: vi.fn(() => () => undefined), | ||
| 87 | subscribeThread: mockSubscribeThread, | ||
| 88 | }, | ||
| 89 | terminal: { | ||
| 90 | open: vi.fn(), | ||
| 91 | attach: vi.fn(() => () => undefined), | ||
| 92 | write: vi.fn(), | ||
| 93 | resize: vi.fn(), | ||
| 94 | clear: vi.fn(), | ||
| 95 | restart: vi.fn(), | ||
| 96 | close: vi.fn(), | ||
| 97 | onEvent: vi.fn(() => () => undefined), | ||
| 98 | onMetadata: vi.fn(() => () => undefined), | ||
| 99 | }, | ||
| 100 | projects: { | ||
| 101 | searchEntries: vi.fn(), | ||
| 102 | writeFile: vi.fn(), | ||
| 103 | }, | ||
| 104 | filesystem: { | ||
| 105 | browse: vi.fn(), | ||
| 106 | }, | ||
| 107 | sourceControl: { | ||
| 108 | lookupRepository: vi.fn(), | ||
| 109 | cloneRepository: vi.fn(), | ||
| 110 | publishRepository: vi.fn(), | ||
| 111 | }, | ||
| 112 | shell: { | ||
| 113 | openInEditor: vi.fn(), | ||
| 114 | }, | ||
| 115 | vcs: { | ||
| 116 | pull: vi.fn(), | ||
| 117 | refreshStatus: vi.fn(), | ||
| 118 | onStatus: vi.fn(() => () => undefined), | ||
| 119 | listRefs: vi.fn(), | ||
| 120 | createWorktree: vi.fn(), | ||
| 121 | removeWorktree: vi.fn(), | ||
| 122 | createRef: vi.fn(), | ||
| 123 | switchRef: vi.fn(), | ||
| 124 | init: vi.fn(), | ||
| 125 | }, | ||
| 126 | git: { | ||
| 127 | runStackedAction: vi.fn(), | ||
| 128 | resolvePullRequest: vi.fn(), | ||
| 129 | preparePullRequestThread: vi.fn(), | ||
| 130 | }, | ||
| 131 | review: { | ||
| 132 | getDiffPreview: vi.fn(), | ||
| 133 | }, | ||
| 134 | server: { | ||
| 135 | getConfig: vi.fn(), | ||
| 136 | refreshProviders: vi.fn(), | ||
| 137 | discoverSourceControl: vi.fn(), | ||
| 138 | updateProvider: vi.fn(), | ||
| 139 | upsertKeybinding: vi.fn(), | ||
| 140 | removeKeybinding: vi.fn(), | ||
| 141 | getSettings: vi.fn(), | ||
| 142 | updateSettings: vi.fn(), | ||
| 143 | subscribeConfig: vi.fn(() => () => undefined), | ||
| 144 | subscribeLifecycle: vi.fn(() => () => undefined), | ||
| 145 | subscribeAuthAccess: vi.fn(() => () => undefined), | ||
| 146 | getTraceDiagnostics: vi.fn(), | ||
| 147 | getProcessDiagnostics: vi.fn(), | ||
| 148 | getProcessResourceHistory: vi.fn(), | ||
| 149 | signalProcess: vi.fn(), | ||
| 150 | }, | ||
| 151 | }; | ||
| 152 | return { | ||
| 153 | ...actual, | ||
| 154 | createWsRpcClient: vi.fn(() => stubWsClient), | ||
| 155 | fetchRemoteSessionState: mockFetchRemoteSessionState, | ||
| 156 | isRemoteEnvironmentAuthHttpError: vi.fn(() => false), | ||
| 157 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | ||
| 158 | }; | ||
| 159 | }); | ||
| 76 | 160 | ||
| 77 | vi.mock("../../rpc/wsTransport", () => ({ | 161 | vi.mock("../../rpc/wsTransport", () => ({ |
| 78 | WsTransport: MockWsTransport, | 162 | WsTransport: MockWsTransport, |
| ... | @@ -184,6 +268,17 @@ describe("retainThreadDetailSubscription", () => { | ... | @@ -184,6 +268,17 @@ describe("retainThreadDetailSubscription", () => { |
| 184 | mockCreateEnvironmentConnection.mockImplementation((input) => { | 268 | mockCreateEnvironmentConnection.mockImplementation((input) => { |
| 185 | const reconnect = vi.fn(async () => undefined); | 269 | const reconnect = vi.fn(async () => undefined); |
| 186 | mockConnectionReconnects.push(reconnect); | 270 | mockConnectionReconnects.push(reconnect); |
| 271 | queueMicrotask(() => { | ||
| 272 | input.onConfigSnapshot?.({ | ||
| 273 | environment: { | ||
| 274 | environmentId: input.knownEnvironment.environmentId, | ||
| 275 | label: input.knownEnvironment.label, | ||
| 276 | platform: { os: "darwin", arch: "arm64" }, | ||
| 277 | serverVersion: "0.0.0-test", | ||
| 278 | capabilities: { repositoryIdentity: true }, | ||
| 279 | }, | ||
| 280 | }); | ||
| 281 | }); | ||
| 187 | return { | 282 | return { |
| 188 | kind: input.kind, | 283 | kind: input.kind, |
| 189 | environmentId: input.knownEnvironment.environmentId, | 284 | environmentId: input.knownEnvironment.environmentId, |
| ... | @@ -353,13 +448,13 @@ describe("retainThreadDetailSubscription", () => { | ... | @@ -353,13 +448,13 @@ describe("retainThreadDetailSubscription", () => { |
| 353 | const stop = startEnvironmentConnectionService(new QueryClient()); | 448 | const stop = startEnvironmentConnectionService(new QueryClient()); |
| 354 | savedEnvironmentRegistryListener?.(); | 449 | savedEnvironmentRegistryListener?.(); |
| 355 | await vi.waitFor(() => { | 450 | await vi.waitFor(() => { |
| 356 | expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(2); | ||
| 357 | expect( | 451 | expect( |
| 358 | listEnvironmentConnections().some( | 452 | listEnvironmentConnections().some( |
| 359 | (connection) => connection.environmentId === environmentId, | 453 | (connection) => connection.environmentId === environmentId, |
| 360 | ), | 454 | ), |
| 361 | ).toBe(true); | 455 | ).toBe(true); |
| 362 | }); | 456 | }); |
| 457 | const createConnectionCallsBeforeReconnect = mockCreateEnvironmentConnection.mock.calls.length; | ||
| 363 | 458 | ||
| 364 | const release = retainThreadDetailSubscription(environmentId, threadId); | 459 | const release = retainThreadDetailSubscription(environmentId, threadId); |
| 365 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); | 460 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); |
| ... | @@ -374,7 +469,9 @@ describe("retainThreadDetailSubscription", () => { | ... | @@ -374,7 +469,9 @@ describe("retainThreadDetailSubscription", () => { |
| 374 | await vi.advanceTimersByTimeAsync(200); | 469 | await vi.advanceTimersByTimeAsync(200); |
| 375 | await reconnectPromise; | 470 | await reconnectPromise; |
| 376 | await vi.waitFor(() => { | 471 | await vi.waitFor(() => { |
| 377 | expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(3); | 472 | expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes( |
| 473 | createConnectionCallsBeforeReconnect + 1, | ||
| 474 | ); | ||
| 378 | expect(mockSubscribeThread).toHaveBeenCalledTimes(2); | 475 | expect(mockSubscribeThread).toHaveBeenCalledTimes(2); |
| 379 | }); | 476 | }); |
| 380 | 477 | ||
| ... | @@ -401,6 +498,33 @@ describe("retainThreadDetailSubscription", () => { | ... | @@ -401,6 +498,33 @@ describe("retainThreadDetailSubscription", () => { |
| 401 | 498 | ||
| 402 | const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = | 499 | const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = |
| 403 | await import("./service"); | 500 | await import("./service"); |
| 501 | mockCreateEnvironmentConnection.mockImplementation((input) => { | ||
| 502 | const reconnect = vi.fn(async () => undefined); | ||
| 503 | mockConnectionReconnects.push(reconnect); | ||
| 504 | queueMicrotask(() => { | ||
| 505 | input.onConfigSnapshot?.({ | ||
| 506 | environment: { | ||
| 507 | environmentId: input.knownEnvironment.environmentId, | ||
| 508 | label: input.knownEnvironment.label, | ||
| 509 | platform: { os: "darwin", arch: "arm64" }, | ||
| 510 | serverVersion: "0.0.0-test", | ||
| 511 | capabilities: { repositoryIdentity: true }, | ||
| 512 | }, | ||
| 513 | }); | ||
| 514 | }); | ||
| 515 | return { | ||
| 516 | kind: input.kind, | ||
| 517 | environmentId: input.knownEnvironment.environmentId, | ||
| 518 | knownEnvironment: input.knownEnvironment, | ||
| 519 | client: { | ||
| 520 | ...input.client, | ||
| 521 | isHeartbeatFresh: vi.fn(() => true), | ||
| 522 | }, | ||
| 523 | ensureBootstrapped: vi.fn(async () => undefined), | ||
| 524 | reconnect, | ||
| 525 | dispose: vi.fn(async () => undefined), | ||
| 526 | }; | ||
| 527 | }); | ||
| 404 | 528 | ||
| 405 | const stop = startEnvironmentConnectionService(new QueryClient()); | 529 | const stop = startEnvironmentConnectionService(new QueryClient()); |
| 406 | expect(mockConnectionReconnects).toHaveLength(1); | 530 | expect(mockConnectionReconnects).toHaveLength(1); |
apps/web/src/environments/runtime/service.ts+119-88| ... | @@ -7,9 +7,18 @@ import { | ... | @@ -7,9 +7,18 @@ import { |
| 7 | type OrchestrationShellSnapshot, | 7 | type OrchestrationShellSnapshot, |
| 8 | type OrchestrationShellStreamEvent, | 8 | type OrchestrationShellStreamEvent, |
| 9 | type ServerConfig, | 9 | type ServerConfig, |
| 10 | type TerminalEvent, | ||
| 11 | ThreadId, | 10 | ThreadId, |
| 12 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { | ||
| 13 | createWsRpcClient as createBaseWsRpcClient, | ||
| 14 | type WsRpcClient, | ||
| 15 | bootstrapRemoteBearerSession, | ||
| 16 | fetchRemoteEnvironmentDescriptor, | ||
| 17 | fetchRemoteSessionState, | ||
| 18 | isRemoteEnvironmentAuthHttpError, | ||
| 19 | resolveRemoteWebSocketConnectionUrl, | ||
| 20 | } from "@t3tools/client-runtime"; | ||
| 21 | |||
| 13 | import { type QueryClient } from "@tanstack/react-query"; | 22 | import { type QueryClient } from "@tanstack/react-query"; |
| 14 | import { Throttler } from "@tanstack/react-pacer"; | 23 | import { Throttler } from "@tanstack/react-pacer"; |
| 15 | import { | 24 | import { |
| ... | @@ -26,19 +35,11 @@ import { | ... | @@ -26,19 +35,11 @@ import { |
| 26 | useComposerDraftStore, | 35 | useComposerDraftStore, |
| 27 | } from "~/composerDraftStore"; | 36 | } from "~/composerDraftStore"; |
| 28 | import { ensureLocalApi } from "~/localApi"; | 37 | import { ensureLocalApi } from "~/localApi"; |
| 29 | import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup"; | 38 | import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup"; |
| 30 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; | 39 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; |
| 31 | import { projectQueryKeys } from "~/lib/projectReactQuery"; | ||
| 32 | import { providerQueryKeys } from "~/lib/providerReactQuery"; | ||
| 33 | import { getPrimaryKnownEnvironment } from "../primary"; | 40 | import { getPrimaryKnownEnvironment } from "../primary"; |
| 34 | import { | 41 | import { remoteHttpRuntime } from "../../lib/runtime"; |
| 35 | bootstrapRemoteBearerSession, | 42 | |
| 36 | fetchRemoteEnvironmentDescriptor, | ||
| 37 | fetchRemoteSessionState, | ||
| 38 | isRemoteEnvironmentAuthHttpError, | ||
| 39 | resolveRemoteWebSocketConnectionUrl, | ||
| 40 | } from "../remote/api"; | ||
| 41 | import { resolveRemotePairingTarget } from "../remote/target"; | ||
| 42 | import { | 43 | import { |
| 43 | getSavedEnvironmentRecord, | 44 | getSavedEnvironmentRecord, |
| 44 | hasSavedEnvironmentRegistryHydrated, | 45 | hasSavedEnvironmentRegistryHydrated, |
| ... | @@ -53,7 +54,12 @@ import { | ... | @@ -53,7 +54,12 @@ import { |
| 53 | waitForSavedEnvironmentRegistryHydration, | 54 | waitForSavedEnvironmentRegistryHydration, |
| 54 | writeSavedEnvironmentBearerToken, | 55 | writeSavedEnvironmentBearerToken, |
| 55 | } from "./catalog"; | 56 | } from "./catalog"; |
| 56 | import { createEnvironmentConnection, type EnvironmentConnection } from "./connection"; | 57 | import { |
| 58 | createEnvironmentConnection, | ||
| 59 | createEnvironmentConnectionAttemptRegistry, | ||
| 60 | EnvironmentConnectionAttemptCancelledError, | ||
| 61 | type EnvironmentConnection, | ||
| 62 | } from "./connection"; | ||
| 57 | import { | 63 | import { |
| 58 | useStore, | 64 | useStore, |
| 59 | selectProjectsAcrossEnvironments, | 65 | selectProjectsAcrossEnvironments, |
| ... | @@ -61,18 +67,19 @@ import { | ... | @@ -61,18 +67,19 @@ import { |
| 61 | selectThreadByRef, | 67 | selectThreadByRef, |
| 62 | selectThreadsAcrossEnvironments, | 68 | selectThreadsAcrossEnvironments, |
| 63 | } from "~/store"; | 69 | } from "~/store"; |
| 64 | import { useTerminalStateStore } from "~/terminalStateStore"; | 70 | import { useTerminalUiStateStore } from "~/terminalUiStateStore"; |
| 65 | import { useUiStateStore } from "~/uiStateStore"; | 71 | import { useUiStateStore } from "~/uiStateStore"; |
| 66 | import type { WsProtocolCloseContext } from "../../rpc/protocol"; | ||
| 67 | import { getServerConfig } from "../../rpc/serverState"; | 72 | import { getServerConfig } from "../../rpc/serverState"; |
| 68 | import { WsTransport } from "../../rpc/wsTransport"; | 73 | import { WsTransport } from "~/rpc/wsTransport"; |
| 69 | import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; | ||
| 70 | import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew"; | 74 | import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew"; |
| 71 | import { | 75 | import { |
| 72 | deriveLogicalProjectKeyFromSettings, | 76 | deriveLogicalProjectKeyFromSettings, |
| 73 | derivePhysicalProjectKey, | 77 | derivePhysicalProjectKey, |
| 74 | } from "../../logicalProject"; | 78 | } from "../../logicalProject"; |
| 75 | import { getClientSettings } from "~/hooks/useSettings"; | 79 | import { getClientSettings } from "~/hooks/useSettings"; |
| 80 | import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; | ||
| 81 | import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; | ||
| 82 | import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; | ||
| 76 | 83 | ||
| 77 | type EnvironmentServiceState = { | 84 | type EnvironmentServiceState = { |
| 78 | readonly queryClient: QueryClient; | 85 | readonly queryClient: QueryClient; |
| ... | @@ -92,29 +99,25 @@ type ThreadDetailSubscriptionEntry = { | ... | @@ -92,29 +99,25 @@ type ThreadDetailSubscriptionEntry = { |
| 92 | }; | 99 | }; |
| 93 | 100 | ||
| 94 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); | 101 | const environmentConnections = new Map<EnvironmentId, EnvironmentConnection>(); |
| 95 | class SavedEnvironmentConnectionCancelledError extends Error { | ||
| 96 | constructor(environmentId: EnvironmentId) { | ||
| 97 | super(`Saved environment ${environmentId} connection was cancelled.`); | ||
| 98 | this.name = "SavedEnvironmentConnectionCancelledError"; | ||
| 99 | } | ||
| 100 | } | ||
| 101 | 102 | ||
| 102 | function isSavedEnvironmentConnectionCancelledError( | 103 | function isSavedEnvironmentConnectionCancelledError( |
| 103 | error: unknown, | 104 | error: unknown, |
| 104 | ): error is SavedEnvironmentConnectionCancelledError { | 105 | ): error is EnvironmentConnectionAttemptCancelledError { |
| 105 | return error instanceof SavedEnvironmentConnectionCancelledError; | 106 | return error instanceof EnvironmentConnectionAttemptCancelledError; |
| 106 | } | 107 | } |
| 107 | 108 | ||
| 108 | interface PendingSavedEnvironmentConnection { | 109 | interface PendingSavedEnvironmentConnection { |
| 109 | cancelled: boolean; | 110 | readonly isCurrent: () => boolean; |
| 110 | readonly promise: Promise<EnvironmentConnection>; | 111 | readonly promise: Promise<EnvironmentConnection>; |
| 111 | } | 112 | } |
| 112 | 113 | ||
| 114 | const savedEnvironmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); | ||
| 113 | const pendingSavedEnvironmentConnections = new Map< | 115 | const pendingSavedEnvironmentConnections = new Map< |
| 114 | EnvironmentId, | 116 | EnvironmentId, |
| 115 | PendingSavedEnvironmentConnection | 117 | PendingSavedEnvironmentConnection |
| 116 | >(); | 118 | >(); |
| 117 | const environmentConnectionListeners = new Set<() => void>(); | 119 | const environmentConnectionListeners = new Set<() => void>(); |
| 120 | const providerInvalidationListeners = new Set<() => void>(); | ||
| 118 | const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>(); | 121 | const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry>(); |
| 119 | const lastAppliedProjectionVersionByEnvironment = new Map< | 122 | const lastAppliedProjectionVersionByEnvironment = new Map< |
| 120 | EnvironmentId, | 123 | EnvironmentId, |
| ... | @@ -123,12 +126,20 @@ const lastAppliedProjectionVersionByEnvironment = new Map< | ... | @@ -123,12 +126,20 @@ const lastAppliedProjectionVersionByEnvironment = new Map< |
| 123 | readonly updatedAt: string | null; | 126 | readonly updatedAt: string | null; |
| 124 | } | 127 | } |
| 125 | >(); | 128 | >(); |
| 129 | const terminalMetadataSubscriptions = new Map<EnvironmentId, () => void>(); | ||
| 126 | 130 | ||
| 127 | let activeService: EnvironmentServiceState | null = null; | 131 | let activeService: EnvironmentServiceState | null = null; |
| 128 | let needsProviderInvalidation = false; | 132 | let needsProviderInvalidation = false; |
| 129 | let lastBrowserHiddenAt: number | null = null; | 133 | let lastBrowserHiddenAt: number | null = null; |
| 130 | let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; | 134 | let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; |
| 131 | 135 | ||
| 136 | // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): | ||
| 137 | // This file still owns web's legacy thread-detail subscription cache. Mobile | ||
| 138 | // uses createThreadDetailManager from @t3tools/client-runtime for the same | ||
| 139 | // retain/reconnect/evict lifecycle. When touching this logic, prefer migrating | ||
| 140 | // web to the shared manager or extracting the missing adapter layer instead of | ||
| 141 | // adding more behavior here. | ||
| 142 | // | ||
| 132 | // Thread detail subscription cache policy: | 143 | // Thread detail subscription cache policy: |
| 133 | // - Active consumers keep a subscription retained via refCount. | 144 | // - Active consumers keep a subscription retained via refCount. |
| 134 | // - Released subscriptions stay warm for a longer idle TTL to avoid churn | 145 | // - Released subscriptions stay warm for a longer idle TTL to avoid churn |
| ... | @@ -592,6 +603,12 @@ function emitEnvironmentConnectionRegistryChange() { | ... | @@ -592,6 +603,12 @@ function emitEnvironmentConnectionRegistryChange() { |
| 592 | } | 603 | } |
| 593 | } | 604 | } |
| 594 | 605 | ||
| 606 | function emitProviderInvalidation() { | ||
| 607 | for (const listener of providerInvalidationListeners) { | ||
| 608 | listener(); | ||
| 609 | } | ||
| 610 | } | ||
| 611 | |||
| 595 | function getRuntimeErrorFields(error: unknown) { | 612 | function getRuntimeErrorFields(error: unknown) { |
| 596 | return { | 613 | return { |
| 597 | lastError: error instanceof Error ? error.message : String(error), | 614 | lastError: error instanceof Error ? error.message : String(error), |
| ... | @@ -932,7 +949,7 @@ function reconcileSnapshotDerivedState() { | ... | @@ -932,7 +949,7 @@ function reconcileSnapshotDerivedState() { |
| 932 | syncThreadUiFromStore(); | 949 | syncThreadUiFromStore(); |
| 933 | 950 | ||
| 934 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); | 951 | const threads = selectThreadsAcrossEnvironments(useStore.getState()); |
| 935 | const activeThreadKeys = collectActiveTerminalThreadIds({ | 952 | const activeThreadKeys = collectActiveTerminalUiThreadKeys({ |
| 936 | snapshotThreads: threads.map((thread) => ({ | 953 | snapshotThreads: threads.map((thread) => ({ |
| 937 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | 954 | key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| 938 | deletedAt: null, | 955 | deletedAt: null, |
| ... | @@ -940,18 +957,7 @@ function reconcileSnapshotDerivedState() { | ... | @@ -940,18 +957,7 @@ function reconcileSnapshotDerivedState() { |
| 940 | })), | 957 | })), |
| 941 | draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), | 958 | draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), |
| 942 | }); | 959 | }); |
| 943 | useTerminalStateStore.getState().removeOrphanedTerminalStates(activeThreadKeys); | 960 | useTerminalUiStateStore.getState().removeOrphanedTerminalUiStates(activeThreadKeys); |
| 944 | } | ||
| 945 | |||
| 946 | export function shouldApplyTerminalEvent(input: { | ||
| 947 | serverThreadArchivedAt: string | null | undefined; | ||
| 948 | hasDraftThread: boolean; | ||
| 949 | }): boolean { | ||
| 950 | if (input.serverThreadArchivedAt !== undefined) { | ||
| 951 | return input.serverThreadArchivedAt === null; | ||
| 952 | } | ||
| 953 | |||
| 954 | return input.hasDraftThread; | ||
| 955 | } | 961 | } |
| 956 | 962 | ||
| 957 | function applyRecoveredEventBatch( | 963 | function applyRecoveredEventBatch( |
| ... | @@ -1017,8 +1023,10 @@ function applyRecoveredEventBatch( | ... | @@ -1017,8 +1023,10 @@ function applyRecoveredEventBatch( |
| 1017 | draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId)); | 1023 | draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId)); |
| 1018 | } | 1024 | } |
| 1019 | } | 1025 | } |
| 1020 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { | 1026 | for (const threadId of batchEffects.removeTerminalUiStateThreadIds) { |
| 1021 | useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId)); | 1027 | useTerminalUiStateStore |
| 1028 | .getState() | ||
| 1029 | .removeTerminalUiState(scopeThreadRef(environmentId, threadId)); | ||
| 1022 | } | 1030 | } |
| 1023 | 1031 | ||
| 1024 | reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); | 1032 | reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); |
| ... | @@ -1064,7 +1072,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En | ... | @@ -1064,7 +1072,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En |
| 1064 | markPromotedDraftThreadByRef(threadRef); | 1072 | markPromotedDraftThreadByRef(threadRef); |
| 1065 | } | 1073 | } |
| 1066 | if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) { | 1074 | if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) { |
| 1067 | useTerminalStateStore.getState().removeTerminalState(threadRef); | 1075 | useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); |
| 1068 | } | 1076 | } |
| 1069 | reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id); | 1077 | reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id); |
| 1070 | evictIdleThreadDetailSubscriptionsToCapacity(); | 1078 | evictIdleThreadDetailSubscriptionsToCapacity(); |
| ... | @@ -1074,7 +1082,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En | ... | @@ -1074,7 +1082,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En |
| 1074 | disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef)); | 1082 | disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef)); |
| 1075 | useComposerDraftStore.getState().clearDraftThread(threadRef); | 1083 | useComposerDraftStore.getState().clearDraftThread(threadRef); |
| 1076 | useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef)); | 1084 | useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef)); |
| 1077 | useTerminalStateStore.getState().removeTerminalState(threadRef); | 1085 | useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); |
| 1078 | } | 1086 | } |
| 1079 | syncThreadUiFromStore(); | 1087 | syncThreadUiFromStore(); |
| 1080 | return; | 1088 | return; |
| ... | @@ -1085,6 +1093,11 @@ function createEnvironmentConnectionHandlers() { | ... | @@ -1085,6 +1093,11 @@ function createEnvironmentConnectionHandlers() { |
| 1085 | return { | 1093 | return { |
| 1086 | applyShellEvent, | 1094 | applyShellEvent, |
| 1087 | syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { | 1095 | syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { |
| 1096 | // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): | ||
| 1097 | // Shell snapshots already have createShellSnapshotManager in | ||
| 1098 | // @t3tools/client-runtime. Web currently projects snapshots straight into | ||
| 1099 | // its denormalized Zustand store; future shell changes should migrate or | ||
| 1100 | // bridge to the shared manager instead of growing this handler. | ||
| 1088 | if ( | 1101 | if ( |
| 1089 | !shouldApplyProjectionSnapshot({ | 1102 | !shouldApplyProjectionSnapshot({ |
| 1090 | current: readLastAppliedProjectionVersion(environmentId), | 1103 | current: readLastAppliedProjectionVersion(environmentId), |
| ... | @@ -1103,24 +1116,15 @@ function createEnvironmentConnectionHandlers() { | ... | @@ -1103,24 +1116,15 @@ function createEnvironmentConnectionHandlers() { |
| 1103 | reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); | 1116 | reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); |
| 1104 | reconcileSnapshotDerivedState(); | 1117 | reconcileSnapshotDerivedState(); |
| 1105 | }, | 1118 | }, |
| 1106 | applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { | ||
| 1107 | const threadRef = scopeThreadRef(environmentId, ThreadId.make(event.threadId)); | ||
| 1108 | const serverThread = selectThreadByRef(useStore.getState(), threadRef); | ||
| 1109 | const hasDraftThread = | ||
| 1110 | useComposerDraftStore.getState().getDraftThreadByRef(threadRef) !== null; | ||
| 1111 | if ( | ||
| 1112 | !shouldApplyTerminalEvent({ | ||
| 1113 | serverThreadArchivedAt: serverThread?.archivedAt, | ||
| 1114 | hasDraftThread, | ||
| 1115 | }) | ||
| 1116 | ) { | ||
| 1117 | return; | ||
| 1118 | } | ||
| 1119 | useTerminalStateStore.getState().applyTerminalEvent(threadRef, event); | ||
| 1120 | }, | ||
| 1121 | }; | 1119 | }; |
| 1122 | } | 1120 | } |
| 1123 | 1121 | ||
| 1122 | function createWsRpcClient(transport: WsTransport): WsRpcClient { | ||
| 1123 | return createBaseWsRpcClient(transport, { | ||
| 1124 | beforeReconnect: () => resetWsReconnectBackoff(), | ||
| 1125 | }); | ||
| 1126 | } | ||
| 1127 | |||
| 1124 | function createPrimaryEnvironmentClient( | 1128 | function createPrimaryEnvironmentClient( |
| 1125 | knownEnvironment: ReturnType<typeof getPrimaryKnownEnvironment>, | 1129 | knownEnvironment: ReturnType<typeof getPrimaryKnownEnvironment>, |
| 1126 | ) { | 1130 | ) { |
| ... | @@ -1160,11 +1164,13 @@ function createSavedEnvironmentClient( | ... | @@ -1160,11 +1164,13 @@ function createSavedEnvironmentClient( |
| 1160 | record.httpBaseUrl, | 1164 | record.httpBaseUrl, |
| 1161 | bearerToken, | 1165 | bearerToken, |
| 1162 | ) | 1166 | ) |
| 1163 | : await resolveRemoteWebSocketConnectionUrl({ | 1167 | : await remoteHttpRuntime.runPromise( |
| 1164 | wsBaseUrl: record.wsBaseUrl, | 1168 | resolveRemoteWebSocketConnectionUrl({ |
| 1165 | httpBaseUrl: record.httpBaseUrl, | 1169 | wsBaseUrl: record.wsBaseUrl, |
| 1166 | bearerToken, | 1170 | httpBaseUrl: record.httpBaseUrl, |
| 1167 | }); | 1171 | bearerToken, |
| 1172 | }), | ||
| 1173 | ); | ||
| 1168 | }, | 1174 | }, |
| 1169 | { | 1175 | { |
| 1170 | getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, | 1176 | getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, |
| ... | @@ -1188,13 +1194,7 @@ function createSavedEnvironmentClient( | ... | @@ -1188,13 +1194,7 @@ function createSavedEnvironmentClient( |
| 1188 | lastErrorAt: isoNow(), | 1194 | lastErrorAt: isoNow(), |
| 1189 | }); | 1195 | }); |
| 1190 | }, | 1196 | }, |
| 1191 | onClose: ( | 1197 | onClose: (details: { readonly code: number; readonly reason: string }) => { |
| 1192 | details: { readonly code: number; readonly reason: string }, | ||
| 1193 | context: WsProtocolCloseContext, | ||
| 1194 | ) => { | ||
| 1195 | if (context.intentional) { | ||
| 1196 | return; | ||
| 1197 | } | ||
| 1198 | setRuntimeDisconnected( | 1198 | setRuntimeDisconnected( |
| 1199 | environmentId, | 1199 | environmentId, |
| 1200 | appendVersionMismatchHint( | 1200 | appendVersionMismatchHint( |
| ... | @@ -1226,10 +1226,12 @@ async function refreshSavedEnvironmentMetadata( | ... | @@ -1226,10 +1226,12 @@ async function refreshSavedEnvironmentMetadata( |
| 1226 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), | 1226 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), |
| 1227 | record.desktopSsh | 1227 | record.desktopSsh |
| 1228 | ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken) | 1228 | ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken) |
| 1229 | : fetchRemoteSessionState({ | 1229 | : remoteHttpRuntime.runPromise( |
| 1230 | httpBaseUrl: record.httpBaseUrl, | 1230 | fetchRemoteSessionState({ |
| 1231 | bearerToken, | 1231 | httpBaseUrl: record.httpBaseUrl, |
| 1232 | }), | 1232 | bearerToken, |
| 1233 | }), | ||
| 1234 | ), | ||
| 1233 | ]); | 1235 | ]); |
| 1234 | 1236 | ||
| 1235 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 1237 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| ... | @@ -1249,6 +1251,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne | ... | @@ -1249,6 +1251,14 @@ function registerConnection(connection: EnvironmentConnection): EnvironmentConne |
| 1249 | throw new Error(`Environment ${connection.environmentId} already has an active connection.`); | 1251 | throw new Error(`Environment ${connection.environmentId} already has an active connection.`); |
| 1250 | } | 1252 | } |
| 1251 | environmentConnections.set(connection.environmentId, connection); | 1253 | environmentConnections.set(connection.environmentId, connection); |
| 1254 | terminalMetadataSubscriptions.get(connection.environmentId)?.(); | ||
| 1255 | terminalMetadataSubscriptions.set( | ||
| 1256 | connection.environmentId, | ||
| 1257 | subscribeTerminalMetadata({ | ||
| 1258 | environmentId: connection.environmentId, | ||
| 1259 | client: connection.client, | ||
| 1260 | }), | ||
| 1261 | ); | ||
| 1252 | attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); | 1262 | attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); |
| 1253 | emitEnvironmentConnectionRegistryChange(); | 1263 | emitEnvironmentConnectionRegistryChange(); |
| 1254 | return connection; | 1264 | return connection; |
| ... | @@ -1262,6 +1272,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> | ... | @@ -1262,6 +1272,9 @@ async function removeConnection(environmentId: EnvironmentId): Promise<boolean> |
| 1262 | 1272 | ||
| 1263 | lastAppliedProjectionVersionByEnvironment.delete(environmentId); | 1273 | lastAppliedProjectionVersionByEnvironment.delete(environmentId); |
| 1264 | environmentConnections.delete(environmentId); | 1274 | environmentConnections.delete(environmentId); |
| 1275 | terminalMetadataSubscriptions.get(environmentId)?.(); | ||
| 1276 | terminalMetadataSubscriptions.delete(environmentId); | ||
| 1277 | terminalSessionManager.invalidateEnvironment(environmentId); | ||
| 1265 | emitEnvironmentConnectionRegistryChange(); | 1278 | emitEnvironmentConnectionRegistryChange(); |
| 1266 | detachThreadDetailSubscriptionsForEnvironment(environmentId); | 1279 | detachThreadDetailSubscriptionsForEnvironment(environmentId); |
| 1267 | await connection.dispose(); | 1280 | await connection.dispose(); |
| ... | @@ -1312,8 +1325,9 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1312,8 +1325,9 @@ async function ensureSavedEnvironmentConnection( |
| 1312 | return pending.promise; | 1325 | return pending.promise; |
| 1313 | } | 1326 | } |
| 1314 | 1327 | ||
| 1328 | const attempt = savedEnvironmentConnectionAttempts.begin(record.environmentId); | ||
| 1315 | const pendingEntry: PendingSavedEnvironmentConnection = { | 1329 | const pendingEntry: PendingSavedEnvironmentConnection = { |
| 1316 | cancelled: false, | 1330 | isCurrent: attempt.isCurrent, |
| 1317 | promise: Promise.resolve().then(async () => { | 1331 | promise: Promise.resolve().then(async () => { |
| 1318 | let activeRecord = record; | 1332 | let activeRecord = record; |
| 1319 | let roleHint = options?.role ?? null; | 1333 | let roleHint = options?.role ?? null; |
| ... | @@ -1425,16 +1439,16 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1425,16 +1439,16 @@ async function ensureSavedEnvironmentConnection( |
| 1425 | }); | 1439 | }); |
| 1426 | } | 1440 | } |
| 1427 | if ( | 1441 | if ( |
| 1428 | pendingEntry.cancelled || | 1442 | !pendingEntry.isCurrent() || |
| 1429 | pendingSavedEnvironmentConnections.get(activeRecord.environmentId) !== pendingEntry | 1443 | pendingSavedEnvironmentConnections.get(activeRecord.environmentId) !== pendingEntry |
| 1430 | ) { | 1444 | ) { |
| 1431 | await connection.dispose().catch(() => undefined); | 1445 | await connection.dispose().catch(() => undefined); |
| 1432 | throw new SavedEnvironmentConnectionCancelledError(activeRecord.environmentId); | 1446 | throw new EnvironmentConnectionAttemptCancelledError(activeRecord.environmentId); |
| 1433 | } | 1447 | } |
| 1434 | registerConnection(connection); | 1448 | registerConnection(connection); |
| 1435 | return connection; | 1449 | return connection; |
| 1436 | } catch (error) { | 1450 | } catch (error) { |
| 1437 | if (error instanceof SavedEnvironmentConnectionCancelledError) { | 1451 | if (error instanceof EnvironmentConnectionAttemptCancelledError) { |
| 1438 | throw error; | 1452 | throw error; |
| 1439 | } | 1453 | } |
| 1440 | setRuntimeError(activeRecord.environmentId, error); | 1454 | setRuntimeError(activeRecord.environmentId, error); |
| ... | @@ -1451,6 +1465,7 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1451,6 +1465,7 @@ async function ensureSavedEnvironmentConnection( |
| 1451 | return await pendingEntry.promise.finally(() => { | 1465 | return await pendingEntry.promise.finally(() => { |
| 1452 | if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) { | 1466 | if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) { |
| 1453 | pendingSavedEnvironmentConnections.delete(record.environmentId); | 1467 | pendingSavedEnvironmentConnections.delete(record.environmentId); |
| 1468 | savedEnvironmentConnectionAttempts.cancel(record.environmentId); | ||
| 1454 | } | 1469 | } |
| 1455 | }); | 1470 | }); |
| 1456 | } | 1471 | } |
| ... | @@ -1538,6 +1553,13 @@ export function subscribeEnvironmentConnections(listener: () => void): () => voi | ... | @@ -1538,6 +1553,13 @@ export function subscribeEnvironmentConnections(listener: () => void): () => voi |
| 1538 | }; | 1553 | }; |
| 1539 | } | 1554 | } |
| 1540 | 1555 | ||
| 1556 | export function subscribeProviderInvalidations(listener: () => void): () => void { | ||
| 1557 | providerInvalidationListeners.add(listener); | ||
| 1558 | return () => { | ||
| 1559 | providerInvalidationListeners.delete(listener); | ||
| 1560 | }; | ||
| 1561 | } | ||
| 1562 | |||
| 1541 | export function listEnvironmentConnections(): ReadonlyArray<EnvironmentConnection> { | 1563 | export function listEnvironmentConnections(): ReadonlyArray<EnvironmentConnection> { |
| 1542 | return [...environmentConnections.values()]; | 1564 | return [...environmentConnections.values()]; |
| 1543 | } | 1565 | } |
| ... | @@ -1564,7 +1586,7 @@ export async function disconnectSavedEnvironment(environmentId: EnvironmentId): | ... | @@ -1564,7 +1586,7 @@ export async function disconnectSavedEnvironment(environmentId: EnvironmentId): |
| 1564 | const record = getSavedEnvironmentRecord(environmentId); | 1586 | const record = getSavedEnvironmentRecord(environmentId); |
| 1565 | const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId); | 1587 | const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId); |
| 1566 | if (pendingConnection) { | 1588 | if (pendingConnection) { |
| 1567 | pendingConnection.cancelled = true; | 1589 | savedEnvironmentConnectionAttempts.cancel(environmentId); |
| 1568 | pendingSavedEnvironmentConnections.delete(environmentId); | 1590 | pendingSavedEnvironmentConnections.delete(environmentId); |
| 1569 | } | 1591 | } |
| 1570 | const connection = environmentConnections.get(environmentId); | 1592 | const connection = environmentConnections.get(environmentId); |
| ... | @@ -1655,9 +1677,11 @@ export async function addSavedEnvironment(input: { | ... | @@ -1655,9 +1677,11 @@ export async function addSavedEnvironment(input: { |
| 1655 | }); | 1677 | }); |
| 1656 | const descriptor = input.desktopSsh | 1678 | const descriptor = input.desktopSsh |
| 1657 | ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) | 1679 | ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) |
| 1658 | : await fetchRemoteEnvironmentDescriptor({ | 1680 | : await remoteHttpRuntime.runPromise( |
| 1659 | httpBaseUrl: resolvedTarget.httpBaseUrl, | 1681 | fetchRemoteEnvironmentDescriptor({ |
| 1660 | }); | 1682 | httpBaseUrl: resolvedTarget.httpBaseUrl, |
| 1683 | }), | ||
| 1684 | ); | ||
| 1661 | const environmentId = descriptor.environmentId; | 1685 | const environmentId = descriptor.environmentId; |
| 1662 | const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]); | 1686 | const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]); |
| 1663 | const existingRecord = | 1687 | const existingRecord = |
| ... | @@ -1668,10 +1692,12 @@ export async function addSavedEnvironment(input: { | ... | @@ -1668,10 +1692,12 @@ export async function addSavedEnvironment(input: { |
| 1668 | 1692 | ||
| 1669 | const bearerSession = input.desktopSsh | 1693 | const bearerSession = input.desktopSsh |
| 1670 | ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) | 1694 | ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) |
| 1671 | : await bootstrapRemoteBearerSession({ | 1695 | : await remoteHttpRuntime.runPromise( |
| 1672 | httpBaseUrl: resolvedTarget.httpBaseUrl, | 1696 | bootstrapRemoteBearerSession({ |
| 1673 | credential: resolvedTarget.credential, | 1697 | httpBaseUrl: resolvedTarget.httpBaseUrl, |
| 1674 | }); | 1698 | credential: resolvedTarget.credential, |
| 1699 | }), | ||
| 1700 | ); | ||
| 1675 | 1701 | ||
| 1676 | const record: SavedEnvironmentRecord = { | 1702 | const record: SavedEnvironmentRecord = { |
| 1677 | environmentId, | 1703 | environmentId, |
| ... | @@ -1763,8 +1789,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () | ... | @@ -1763,8 +1789,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () |
| 1763 | return; | 1789 | return; |
| 1764 | } | 1790 | } |
| 1765 | needsProviderInvalidation = false; | 1791 | needsProviderInvalidation = false; |
| 1766 | void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); | 1792 | emitProviderInvalidation(); |
| 1767 | void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all }); | ||
| 1768 | }, | 1793 | }, |
| 1769 | { | 1794 | { |
| 1770 | wait: 100, | 1795 | wait: 100, |
| ... | @@ -1817,9 +1842,15 @@ export async function resetEnvironmentServiceForTests(): Promise<void> { | ... | @@ -1817,9 +1842,15 @@ export async function resetEnvironmentServiceForTests(): Promise<void> { |
| 1817 | lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; | 1842 | lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; |
| 1818 | lastAppliedProjectionVersionByEnvironment.clear(); | 1843 | lastAppliedProjectionVersionByEnvironment.clear(); |
| 1819 | pendingSavedEnvironmentConnections.clear(); | 1844 | pendingSavedEnvironmentConnections.clear(); |
| 1845 | savedEnvironmentConnectionAttempts.clear(); | ||
| 1820 | for (const key of Array.from(threadDetailSubscriptions.keys())) { | 1846 | for (const key of Array.from(threadDetailSubscriptions.keys())) { |
| 1821 | disposeThreadDetailSubscriptionByKey(key); | 1847 | disposeThreadDetailSubscriptionByKey(key); |
| 1822 | } | 1848 | } |
| 1849 | for (const unsubscribe of terminalMetadataSubscriptions.values()) { | ||
| 1850 | unsubscribe(); | ||
| 1851 | } | ||
| 1852 | terminalMetadataSubscriptions.clear(); | ||
| 1853 | terminalSessionManager.reset(); | ||
| 1823 | await Promise.all( | 1854 | await Promise.all( |
| 1824 | [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), | 1855 | [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), |
| 1825 | ); | 1856 | ); |
apps/web/src/hooks/useThreadActions.ts+6-9| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 2 | import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 3 | import { useQueryClient } from "@tanstack/react-query"; | ||
| 4 | import { useRouter } from "@tanstack/react-router"; | 3 | import { useRouter } from "@tanstack/react-router"; |
| 5 | import { useCallback, useRef } from "react"; | 4 | import { useCallback, useRef } from "react"; |
| 6 | 5 | ||
| ... | @@ -8,7 +7,7 @@ import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; | ... | @@ -8,7 +7,7 @@ import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; |
| 8 | import { useComposerDraftStore } from "../composerDraftStore"; | 7 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 9 | import { useNewThreadHandler } from "./useHandleNewThread"; | 8 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 10 | import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; | 9 | import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; |
| 11 | import { invalidateGitQueries } from "../lib/gitReactQuery"; | 10 | import { invalidateSourceControlState } from "../lib/sourceControlActions"; |
| 12 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; | 11 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; |
| 13 | import { newCommandId } from "../lib/utils"; | 12 | import { newCommandId } from "../lib/utils"; |
| 14 | import { readLocalApi } from "../localApi"; | 13 | import { readLocalApi } from "../localApi"; |
| ... | @@ -18,7 +17,7 @@ import { | ... | @@ -18,7 +17,7 @@ import { |
| 18 | selectThreadsForEnvironment, | 17 | selectThreadsForEnvironment, |
| 19 | useStore, | 18 | useStore, |
| 20 | } from "../store"; | 19 | } from "../store"; |
| 21 | import { useTerminalStateStore } from "../terminalStateStore"; | 20 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 22 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | 21 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 23 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 22 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 24 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | 23 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| ... | @@ -31,7 +30,7 @@ export function useThreadActions() { | ... | @@ -31,7 +30,7 @@ export function useThreadActions() { |
| 31 | const clearProjectDraftThreadById = useComposerDraftStore( | 30 | const clearProjectDraftThreadById = useComposerDraftStore( |
| 32 | (store) => store.clearProjectDraftThreadById, | 31 | (store) => store.clearProjectDraftThreadById, |
| 33 | ); | 32 | ); |
| 34 | const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); | 33 | const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState); |
| 35 | const router = useRouter(); | 34 | const router = useRouter(); |
| 36 | const { handleNewThread } = useNewThreadHandler(); | 35 | const { handleNewThread } = useNewThreadHandler(); |
| 37 | // Keep a ref so archiveThread can call handleNewThread without appearing in | 36 | // Keep a ref so archiveThread can call handleNewThread without appearing in |
| ... | @@ -40,7 +39,6 @@ export function useThreadActions() { | ... | @@ -40,7 +39,6 @@ export function useThreadActions() { |
| 40 | // sidebar row via archiveThread → attemptArchiveThread. | 39 | // sidebar row via archiveThread → attemptArchiveThread. |
| 41 | const handleNewThreadRef = useRef(handleNewThread); | 40 | const handleNewThreadRef = useRef(handleNewThread); |
| 42 | handleNewThreadRef.current = handleNewThread; | 41 | handleNewThreadRef.current = handleNewThread; |
| 43 | const queryClient = useQueryClient(); | ||
| 44 | 42 | ||
| 45 | const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { | 43 | const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { |
| 46 | const state = useStore.getState(); | 44 | const state = useStore.getState(); |
| ... | @@ -195,7 +193,7 @@ export function useThreadActions() { | ... | @@ -195,7 +193,7 @@ export function useThreadActions() { |
| 195 | scopeProjectRef(threadRef.environmentId, thread.projectId), | 193 | scopeProjectRef(threadRef.environmentId, thread.projectId), |
| 196 | threadRef, | 194 | threadRef, |
| 197 | ); | 195 | ); |
| 198 | clearTerminalState(threadRef); | 196 | clearTerminalUiState(threadRef); |
| 199 | 197 | ||
| 200 | if (shouldNavigateToFallback) { | 198 | if (shouldNavigateToFallback) { |
| 201 | if (fallbackThreadId) { | 199 | if (fallbackThreadId) { |
| ... | @@ -229,7 +227,7 @@ export function useThreadActions() { | ... | @@ -229,7 +227,7 @@ export function useThreadActions() { |
| 229 | path: orphanedWorktreePath, | 227 | path: orphanedWorktreePath, |
| 230 | force: true, | 228 | force: true, |
| 231 | }); | 229 | }); |
| 232 | await invalidateGitQueries(queryClient, { | 230 | await invalidateSourceControlState({ |
| 233 | environmentId: threadRef.environmentId, | 231 | environmentId: threadRef.environmentId, |
| 234 | }); | 232 | }); |
| 235 | } catch (error) { | 233 | } catch (error) { |
| ... | @@ -252,10 +250,9 @@ export function useThreadActions() { | ... | @@ -252,10 +250,9 @@ export function useThreadActions() { |
| 252 | [ | 250 | [ |
| 253 | clearComposerDraftForThread, | 251 | clearComposerDraftForThread, |
| 254 | clearProjectDraftThreadById, | 252 | clearProjectDraftThreadById, |
| 255 | clearTerminalState, | 253 | clearTerminalUiState, |
| 256 | getCurrentRouteThreadRef, | 254 | getCurrentRouteThreadRef, |
| 257 | router, | 255 | router, |
| 258 | queryClient, | ||
| 259 | resolveThreadTarget, | 256 | resolveThreadTarget, |
| 260 | sidebarThreadSortOrder, | 257 | sidebarThreadSortOrder, |
| 261 | ], | 258 | ], |
apps/web/src/lib/archivedThreadsState.ts+16-79| ... | @@ -1,83 +1,23 @@ | ... | @@ -1,83 +1,23 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | 2 | import { |
| 3 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 3 | type ArchivedSnapshotEntry, |
| 4 | createArchivedThreadsManager, | ||
| 5 | makeArchivedThreadsEnvironmentKey, | ||
| 6 | readArchivedThreadsSnapshotState, | ||
| 7 | } from "@t3tools/client-runtime"; | ||
| 8 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | import { useCallback, useMemo } from "react"; | 9 | import { useCallback, useMemo } from "react"; |
| 5 | import * as Cause from "effect/Cause"; | 10 | |
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import { readEnvironmentApi } from "../environmentApi"; | 11 | import { readEnvironmentApi } from "../environmentApi"; |
| 9 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 12 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 10 | 13 | ||
| 11 | const ARCHIVED_THREADS_STALE_TIME_MS = 5_000; | 14 | const archivedThreadsManager = createArchivedThreadsManager({ |
| 12 | const ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000; | 15 | getRegistry: () => appAtomRegistry, |
| 13 | const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; | 16 | getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null, |
| 14 | |||
| 15 | export type ArchivedSnapshotEntry = { | ||
| 16 | readonly environmentId: EnvironmentId; | ||
| 17 | readonly snapshot: OrchestrationShellSnapshot; | ||
| 18 | }; | ||
| 19 | |||
| 20 | const knownArchivedThreadEnvironmentKeys = new Set<string>(); | ||
| 21 | |||
| 22 | function makeArchivedThreadsEnvironmentKey(environmentIds: ReadonlyArray<EnvironmentId>): string { | ||
| 23 | return environmentIds.toSorted().join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR); | ||
| 24 | } | ||
| 25 | |||
| 26 | function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<EnvironmentId> { | ||
| 27 | if (key.length === 0) { | ||
| 28 | return []; | ||
| 29 | } | ||
| 30 | return key | ||
| 31 | .split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR) | ||
| 32 | .map((environmentId) => EnvironmentId.make(environmentId)); | ||
| 33 | } | ||
| 34 | |||
| 35 | const archivedThreadSnapshotsAtom = Atom.family((environmentKey: string) => { | ||
| 36 | knownArchivedThreadEnvironmentKeys.add(environmentKey); | ||
| 37 | return Atom.make( | ||
| 38 | Effect.promise(async (): Promise<ReadonlyArray<ArchivedSnapshotEntry>> => { | ||
| 39 | const environmentIds = parseArchivedThreadsEnvironmentKey(environmentKey); | ||
| 40 | const snapshots = await Promise.all( | ||
| 41 | environmentIds.map(async (environmentId) => { | ||
| 42 | const api = readEnvironmentApi(environmentId); | ||
| 43 | if (!api) { | ||
| 44 | return null; | ||
| 45 | } | ||
| 46 | return { | ||
| 47 | environmentId, | ||
| 48 | snapshot: await api.orchestration.getArchivedShellSnapshot(), | ||
| 49 | }; | ||
| 50 | }), | ||
| 51 | ); | ||
| 52 | return snapshots.filter((snapshot) => snapshot !== null); | ||
| 53 | }), | ||
| 54 | ).pipe( | ||
| 55 | Atom.swr({ | ||
| 56 | staleTime: ARCHIVED_THREADS_STALE_TIME_MS, | ||
| 57 | revalidateOnMount: true, | ||
| 58 | }), | ||
| 59 | Atom.setIdleTTL(ARCHIVED_THREADS_IDLE_TTL_MS), | ||
| 60 | Atom.withLabel(`archived-thread-snapshots:${environmentKey}`), | ||
| 61 | ); | ||
| 62 | }); | 17 | }); |
| 63 | 18 | ||
| 64 | function readArchivedThreadsError( | ||
| 65 | result: AsyncResult.AsyncResult<ReadonlyArray<ArchivedSnapshotEntry>, unknown>, | ||
| 66 | ): string | null { | ||
| 67 | if (result._tag !== "Failure") { | ||
| 68 | return null; | ||
| 69 | } | ||
| 70 | |||
| 71 | const error = Cause.squash(result.cause); | ||
| 72 | return error instanceof Error ? error.message : "Failed to load archived threads."; | ||
| 73 | } | ||
| 74 | |||
| 75 | export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { | 19 | export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { |
| 76 | for (const key of knownArchivedThreadEnvironmentKeys) { | 20 | archivedThreadsManager.refreshForEnvironment(environmentId); |
| 77 | if (parseArchivedThreadsEnvironmentKey(key).includes(environmentId)) { | ||
| 78 | appAtomRegistry.refresh(archivedThreadSnapshotsAtom(key)); | ||
| 79 | } | ||
| 80 | } | ||
| 81 | } | 21 | } |
| 82 | 22 | ||
| 83 | export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): { | 23 | export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): { |
| ... | @@ -90,17 +30,14 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<Environ | ... | @@ -90,17 +30,14 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<Environ |
| 90 | () => makeArchivedThreadsEnvironmentKey(environmentIds), | 30 | () => makeArchivedThreadsEnvironmentKey(environmentIds), |
| 91 | [environmentIds], | 31 | [environmentIds], |
| 92 | ); | 32 | ); |
| 93 | const atom = archivedThreadSnapshotsAtom(environmentKey); | 33 | const atom = archivedThreadsManager.getAtom(environmentKey); |
| 94 | const result = useAtomValue(atom); | 34 | const result = useAtomValue(atom); |
| 95 | const snapshots = Option.getOrElse(AsyncResult.value(result), () => []); | ||
| 96 | const refresh = useCallback(() => { | 35 | const refresh = useCallback(() => { |
| 97 | appAtomRegistry.refresh(atom); | 36 | archivedThreadsManager.refresh(environmentIds); |
| 98 | }, [atom]); | 37 | }, [environmentIds]); |
| 99 | 38 | ||
| 100 | return { | 39 | return { |
| 101 | snapshots, | 40 | ...readArchivedThreadsSnapshotState(result), |
| 102 | error: readArchivedThreadsError(result), | ||
| 103 | isLoading: result.waiting, | ||
| 104 | refresh, | 41 | refresh, |
| 105 | }; | 42 | }; |
| 106 | } | 43 | } |
apps/web/src/lib/checkpointDiffState.ts created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type CheckpointDiffState, | ||
| 4 | type CheckpointDiffTarget, | ||
| 5 | checkpointDiffStateAtom, | ||
| 6 | createCheckpointDiffManager, | ||
| 7 | EMPTY_CHECKPOINT_DIFF_ATOM, | ||
| 8 | EMPTY_CHECKPOINT_DIFF_STATE, | ||
| 9 | getCheckpointDiffTargetKey, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { useEffect, useMemo } from "react"; | ||
| 12 | |||
| 13 | import { readEnvironmentApi } from "../environmentApi"; | ||
| 14 | import { subscribeProviderInvalidations } from "../environments/runtime"; | ||
| 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 16 | |||
| 17 | const checkpointDiffManager = createCheckpointDiffManager({ | ||
| 18 | getRegistry: () => appAtomRegistry, | ||
| 19 | getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null, | ||
| 20 | }); | ||
| 21 | |||
| 22 | export function invalidateCheckpointDiffs(): void { | ||
| 23 | checkpointDiffManager.invalidate(); | ||
| 24 | } | ||
| 25 | |||
| 26 | subscribeProviderInvalidations(invalidateCheckpointDiffs); | ||
| 27 | |||
| 28 | export function useCheckpointDiff( | ||
| 29 | target: CheckpointDiffTarget, | ||
| 30 | options?: { readonly enabled?: boolean }, | ||
| 31 | ): CheckpointDiffState { | ||
| 32 | const stableTarget = useMemo( | ||
| 33 | () => ({ | ||
| 34 | environmentId: target.environmentId, | ||
| 35 | threadId: target.threadId, | ||
| 36 | fromTurnCount: target.fromTurnCount, | ||
| 37 | toTurnCount: target.toTurnCount, | ||
| 38 | ignoreWhitespace: target.ignoreWhitespace, | ||
| 39 | cacheScope: target.cacheScope ?? null, | ||
| 40 | }), | ||
| 41 | [ | ||
| 42 | target.cacheScope, | ||
| 43 | target.environmentId, | ||
| 44 | target.fromTurnCount, | ||
| 45 | target.ignoreWhitespace, | ||
| 46 | target.threadId, | ||
| 47 | target.toTurnCount, | ||
| 48 | ], | ||
| 49 | ); | ||
| 50 | const targetKey = getCheckpointDiffTargetKey(stableTarget); | ||
| 51 | |||
| 52 | useEffect(() => { | ||
| 53 | if (targetKey === null || options?.enabled === false) { | ||
| 54 | return; | ||
| 55 | } | ||
| 56 | void checkpointDiffManager.load(stableTarget); | ||
| 57 | }, [options?.enabled, stableTarget, targetKey]); | ||
| 58 | |||
| 59 | const state = useAtomValue( | ||
| 60 | targetKey !== null ? checkpointDiffStateAtom(targetKey) : EMPTY_CHECKPOINT_DIFF_ATOM, | ||
| 61 | ); | ||
| 62 | return targetKey === null || options?.enabled === false ? EMPTY_CHECKPOINT_DIFF_STATE : state; | ||
| 63 | } | ||
apps/web/src/lib/composerPathSearchState.ts created+57| ... | @@ -0,0 +1,57 @@ | ||
| 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 | subscribeProviderInvalidations, | ||
| 18 | } from "../environments/runtime"; | ||
| 19 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 20 | |||
| 21 | const COMPOSER_PATH_SEARCH_LIMIT = 80; | ||
| 22 | const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; | ||
| 23 | const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; | ||
| 24 | |||
| 25 | const composerPathSearchManager = createComposerPathSearchManager({ | ||
| 26 | getRegistry: () => appAtomRegistry, | ||
| 27 | getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.projects ?? null, | ||
| 28 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 29 | limit: COMPOSER_PATH_SEARCH_LIMIT, | ||
| 30 | debounceMs: COMPOSER_PATH_SEARCH_DEBOUNCE_MS, | ||
| 31 | staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, | ||
| 32 | }); | ||
| 33 | |||
| 34 | export function invalidateComposerPathSearches(): void { | ||
| 35 | composerPathSearchManager.invalidate(); | ||
| 36 | } | ||
| 37 | |||
| 38 | subscribeProviderInvalidations(invalidateComposerPathSearches); | ||
| 39 | |||
| 40 | export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { | ||
| 41 | const stableTarget = useMemo( | ||
| 42 | () => ({ | ||
| 43 | environmentId: target.environmentId, | ||
| 44 | cwd: target.cwd, | ||
| 45 | query: normalizeComposerPathSearchQuery(target.query), | ||
| 46 | }), | ||
| 47 | [target.cwd, target.environmentId, target.query], | ||
| 48 | ); | ||
| 49 | const targetKey = getComposerPathSearchTargetKey(stableTarget); | ||
| 50 | |||
| 51 | useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); | ||
| 52 | |||
| 53 | const state = useAtomValue( | ||
| 54 | targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, | ||
| 55 | ); | ||
| 56 | return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; | ||
| 57 | } | ||
apps/web/src/lib/diffRendering.ts+73| ... | @@ -1,3 +1,6 @@ | ... | @@ -1,3 +1,6 @@ |
| 1 | import { parsePatchFiles } from "@pierre/diffs"; | ||
| 2 | import type { FileDiffMetadata } from "@pierre/diffs/react"; | ||
| 3 | |||
| 1 | export const DIFF_THEME_NAMES = { | 4 | export const DIFF_THEME_NAMES = { |
| 2 | light: "pierre-light", | 5 | light: "pierre-light", |
| 3 | dark: "pierre-dark", | 6 | dark: "pierre-dark", |
| ... | @@ -37,3 +40,73 @@ export function buildPatchCacheKey(patch: string, scope = "diff-panel"): string | ... | @@ -37,3 +40,73 @@ export function buildPatchCacheKey(patch: string, scope = "diff-panel"): string |
| 37 | ).toString(36); | 40 | ).toString(36); |
| 38 | return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`; | 41 | return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`; |
| 39 | } | 42 | } |
| 43 | |||
| 44 | export type RenderablePatch = | ||
| 45 | | { | ||
| 46 | kind: "files"; | ||
| 47 | files: FileDiffMetadata[]; | ||
| 48 | } | ||
| 49 | | { | ||
| 50 | kind: "raw"; | ||
| 51 | text: string; | ||
| 52 | reason: string; | ||
| 53 | }; | ||
| 54 | |||
| 55 | export function getRenderablePatch( | ||
| 56 | patch: string | undefined, | ||
| 57 | cacheScope = "diff-panel", | ||
| 58 | ): RenderablePatch | null { | ||
| 59 | if (!patch) return null; | ||
| 60 | const normalizedPatch = patch.trim(); | ||
| 61 | if (normalizedPatch.length === 0) return null; | ||
| 62 | |||
| 63 | try { | ||
| 64 | const parsedPatches = parsePatchFiles( | ||
| 65 | normalizedPatch, | ||
| 66 | buildPatchCacheKey(normalizedPatch, cacheScope), | ||
| 67 | ); | ||
| 68 | const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); | ||
| 69 | if (files.length > 0) { | ||
| 70 | return { kind: "files", files }; | ||
| 71 | } | ||
| 72 | |||
| 73 | return { | ||
| 74 | kind: "raw", | ||
| 75 | text: normalizedPatch, | ||
| 76 | reason: "Unsupported diff format. Showing raw patch.", | ||
| 77 | }; | ||
| 78 | } catch { | ||
| 79 | return { | ||
| 80 | kind: "raw", | ||
| 81 | text: normalizedPatch, | ||
| 82 | reason: "Failed to parse patch. Showing raw patch.", | ||
| 83 | }; | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { | ||
| 88 | const raw = fileDiff.name ?? fileDiff.prevName ?? ""; | ||
| 89 | if (raw.startsWith("a/") || raw.startsWith("b/")) { | ||
| 90 | return raw.slice(2); | ||
| 91 | } | ||
| 92 | return raw; | ||
| 93 | } | ||
| 94 | |||
| 95 | export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { | ||
| 96 | return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; | ||
| 97 | } | ||
| 98 | |||
| 99 | export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string { | ||
| 100 | switch (fileDiff.type) { | ||
| 101 | case "new": | ||
| 102 | return "text-[var(--diffs-addition-base)]"; | ||
| 103 | case "deleted": | ||
| 104 | return "text-[var(--diffs-deletion-base)]"; | ||
| 105 | case "change": | ||
| 106 | case "rename-pure": | ||
| 107 | case "rename-changed": | ||
| 108 | return "text-[var(--diffs-modified-base)]"; | ||
| 109 | default: | ||
| 110 | return "text-muted-foreground/80"; | ||
| 111 | } | ||
| 112 | } |
apps/web/src/lib/gitReactQuery.test.ts deleted-135| ... | @@ -1,135 +0,0 @@ | ||
| 1 | import { QueryClient } from "@tanstack/react-query"; | ||
| 2 | import { describe, expect, it, vi } from "vitest"; | ||
| 3 | |||
| 4 | vi.mock("../environmentApi", () => ({ | ||
| 5 | ensureEnvironmentApi: vi.fn(), | ||
| 6 | })); | ||
| 7 | |||
| 8 | vi.mock("../wsRpcClient", () => ({ | ||
| 9 | getWsRpcClient: vi.fn(), | ||
| 10 | getWsRpcClientForEnvironment: vi.fn(), | ||
| 11 | })); | ||
| 12 | |||
| 13 | import type { InfiniteData } from "@tanstack/react-query"; | ||
| 14 | import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | gitBranchSearchInfiniteQueryOptions, | ||
| 18 | gitMutationKeys, | ||
| 19 | gitPreparePullRequestThreadMutationOptions, | ||
| 20 | gitPullMutationOptions, | ||
| 21 | gitRunStackedActionMutationOptions, | ||
| 22 | invalidateGitQueries, | ||
| 23 | } from "./gitReactQuery"; | ||
| 24 | |||
| 25 | const BRANCH_QUERY_RESULT: VcsListRefsResult = { | ||
| 26 | refs: [], | ||
| 27 | isRepo: true, | ||
| 28 | hasPrimaryRemote: true, | ||
| 29 | nextCursor: null, | ||
| 30 | totalCount: 0, | ||
| 31 | }; | ||
| 32 | |||
| 33 | const BRANCH_SEARCH_RESULT: InfiniteData<VcsListRefsResult, number> = { | ||
| 34 | pages: [BRANCH_QUERY_RESULT], | ||
| 35 | pageParams: [0], | ||
| 36 | }; | ||
| 37 | const ENVIRONMENT_A = EnvironmentId.make("environment-a"); | ||
| 38 | const ENVIRONMENT_B = EnvironmentId.make("environment-b"); | ||
| 39 | |||
| 40 | describe("gitMutationKeys", () => { | ||
| 41 | it("scopes stacked action keys by cwd", () => { | ||
| 42 | expect(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")).not.toEqual( | ||
| 43 | gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/b"), | ||
| 44 | ); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("scopes pull keys by cwd", () => { | ||
| 48 | expect(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")).not.toEqual( | ||
| 49 | gitMutationKeys.pull(ENVIRONMENT_A, "/repo/b"), | ||
| 50 | ); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("scopes pull request thread preparation keys by cwd", () => { | ||
| 54 | expect(gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a")).not.toEqual( | ||
| 55 | gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/b"), | ||
| 56 | ); | ||
| 57 | }); | ||
| 58 | }); | ||
| 59 | |||
| 60 | describe("git mutation options", () => { | ||
| 61 | const queryClient = new QueryClient(); | ||
| 62 | |||
| 63 | it("attaches cwd-scoped mutation key for runStackedAction", () => { | ||
| 64 | const options = gitRunStackedActionMutationOptions({ | ||
| 65 | environmentId: ENVIRONMENT_A, | ||
| 66 | cwd: "/repo/a", | ||
| 67 | queryClient, | ||
| 68 | }); | ||
| 69 | expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("attaches cwd-scoped mutation key for pull", () => { | ||
| 73 | const options = gitPullMutationOptions({ | ||
| 74 | environmentId: ENVIRONMENT_A, | ||
| 75 | cwd: "/repo/a", | ||
| 76 | queryClient, | ||
| 77 | }); | ||
| 78 | expect(options.mutationKey).toEqual(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("attaches cwd-scoped mutation key for preparePullRequestThread", () => { | ||
| 82 | const options = gitPreparePullRequestThreadMutationOptions({ | ||
| 83 | environmentId: ENVIRONMENT_A, | ||
| 84 | cwd: "/repo/a", | ||
| 85 | queryClient, | ||
| 86 | }); | ||
| 87 | expect(options.mutationKey).toEqual( | ||
| 88 | gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a"), | ||
| 89 | ); | ||
| 90 | }); | ||
| 91 | }); | ||
| 92 | |||
| 93 | describe("invalidateGitQueries", () => { | ||
| 94 | it("can invalidate a single cwd without blasting other git query scopes", async () => { | ||
| 95 | const queryClient = new QueryClient(); | ||
| 96 | |||
| 97 | queryClient.setQueryData( | ||
| 98 | gitBranchSearchInfiniteQueryOptions({ | ||
| 99 | environmentId: ENVIRONMENT_A, | ||
| 100 | cwd: "/repo/a", | ||
| 101 | query: "feature", | ||
| 102 | }).queryKey, | ||
| 103 | BRANCH_SEARCH_RESULT, | ||
| 104 | ); | ||
| 105 | queryClient.setQueryData( | ||
| 106 | gitBranchSearchInfiniteQueryOptions({ | ||
| 107 | environmentId: ENVIRONMENT_B, | ||
| 108 | cwd: "/repo/b", | ||
| 109 | query: "feature", | ||
| 110 | }).queryKey, | ||
| 111 | BRANCH_SEARCH_RESULT, | ||
| 112 | ); | ||
| 113 | |||
| 114 | await invalidateGitQueries(queryClient, { environmentId: ENVIRONMENT_A, cwd: "/repo/a" }); | ||
| 115 | |||
| 116 | expect( | ||
| 117 | queryClient.getQueryState( | ||
| 118 | gitBranchSearchInfiniteQueryOptions({ | ||
| 119 | environmentId: ENVIRONMENT_A, | ||
| 120 | cwd: "/repo/a", | ||
| 121 | query: "feature", | ||
| 122 | }).queryKey, | ||
| 123 | )?.isInvalidated, | ||
| 124 | ).toBe(true); | ||
| 125 | expect( | ||
| 126 | queryClient.getQueryState( | ||
| 127 | gitBranchSearchInfiniteQueryOptions({ | ||
| 128 | environmentId: ENVIRONMENT_B, | ||
| 129 | cwd: "/repo/b", | ||
| 130 | query: "feature", | ||
| 131 | }).queryKey, | ||
| 132 | )?.isInvalidated, | ||
| 133 | ).toBe(false); | ||
| 134 | }); | ||
| 135 | }); | ||
apps/web/src/lib/gitReactQuery.ts deleted-327| ... | @@ -1,327 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type EnvironmentId, | ||
| 3 | type GitActionProgressEvent, | ||
| 4 | type GitStackedAction, | ||
| 5 | type SourceControlPublishRepositoryInput, | ||
| 6 | type ThreadId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { | ||
| 9 | infiniteQueryOptions, | ||
| 10 | mutationOptions, | ||
| 11 | queryOptions, | ||
| 12 | type QueryClient, | ||
| 13 | } from "@tanstack/react-query"; | ||
| 14 | import { ensureEnvironmentApi } from "../environmentApi"; | ||
| 15 | import { requireEnvironmentConnection } from "../environments/runtime"; | ||
| 16 | |||
| 17 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; | ||
| 18 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; | ||
| 19 | const GIT_BRANCHES_PAGE_SIZE = 100; | ||
| 20 | |||
| 21 | export const gitQueryKeys = { | ||
| 22 | all: ["git"] as const, | ||
| 23 | refs: (environmentId: EnvironmentId | null, cwd: string | null) => | ||
| 24 | ["git", "refs", environmentId ?? null, cwd] as const, | ||
| 25 | branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) => | ||
| 26 | ["git", "refs", environmentId ?? null, cwd, "search", query] as const, | ||
| 27 | }; | ||
| 28 | |||
| 29 | export const gitMutationKeys = { | ||
| 30 | init: (environmentId: EnvironmentId | null, cwd: string | null) => | ||
| 31 | ["git", "mutation", "init", environmentId ?? null, cwd] as const, | ||
| 32 | switchRef: (environmentId: EnvironmentId | null, cwd: string | null) => | ||
| 33 | ["git", "mutation", "switchRef", environmentId ?? null, cwd] as const, | ||
| 34 | runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) => | ||
| 35 | ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const, | ||
| 36 | pull: (environmentId: EnvironmentId | null, cwd: string | null) => | ||
| 37 | ["git", "mutation", "pull", environmentId ?? null, cwd] as const, | ||
| 38 | preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) => | ||
| 39 | ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const, | ||
| 40 | publishRepository: (environmentId: EnvironmentId | null, cwd: string | null) => | ||
| 41 | ["git", "mutation", "publish-repository", environmentId ?? null, cwd] as const, | ||
| 42 | }; | ||
| 43 | |||
| 44 | export function invalidateGitQueries( | ||
| 45 | queryClient: QueryClient, | ||
| 46 | input?: { environmentId?: EnvironmentId | null; cwd?: string | null }, | ||
| 47 | ) { | ||
| 48 | const environmentId = input?.environmentId ?? null; | ||
| 49 | const cwd = input?.cwd ?? null; | ||
| 50 | if (cwd !== null) { | ||
| 51 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) }); | ||
| 52 | } | ||
| 53 | |||
| 54 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); | ||
| 55 | } | ||
| 56 | |||
| 57 | function invalidateGitBranchQueries( | ||
| 58 | queryClient: QueryClient, | ||
| 59 | environmentId: EnvironmentId | null, | ||
| 60 | cwd: string | null, | ||
| 61 | ) { | ||
| 62 | if (cwd === null) { | ||
| 63 | return Promise.resolve(); | ||
| 64 | } | ||
| 65 | |||
| 66 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) }); | ||
| 67 | } | ||
| 68 | |||
| 69 | /** | ||
| 70 | * @deprecated Use a VCS-named query helper once the UI naming migration lands. | ||
| 71 | */ | ||
| 72 | export function gitBranchSearchInfiniteQueryOptions(input: { | ||
| 73 | environmentId: EnvironmentId | null; | ||
| 74 | cwd: string | null; | ||
| 75 | query: string; | ||
| 76 | enabled?: boolean; | ||
| 77 | }) { | ||
| 78 | const normalizedQuery = input.query.trim(); | ||
| 79 | |||
| 80 | return infiniteQueryOptions({ | ||
| 81 | queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery), | ||
| 82 | initialPageParam: 0, | ||
| 83 | queryFn: async ({ pageParam }) => { | ||
| 84 | if (!input.cwd) throw new Error("Git refs are unavailable."); | ||
| 85 | if (!input.environmentId) throw new Error("Git refs are unavailable."); | ||
| 86 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 87 | return api.vcs.listRefs({ | ||
| 88 | cwd: input.cwd, | ||
| 89 | ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), | ||
| 90 | cursor: pageParam, | ||
| 91 | limit: GIT_BRANCHES_PAGE_SIZE, | ||
| 92 | }); | ||
| 93 | }, | ||
| 94 | getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, | ||
| 95 | enabled: input.cwd !== null && (input.enabled ?? true), | ||
| 96 | staleTime: GIT_BRANCHES_STALE_TIME_MS, | ||
| 97 | refetchOnWindowFocus: true, | ||
| 98 | refetchOnReconnect: true, | ||
| 99 | refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS, | ||
| 100 | }); | ||
| 101 | } | ||
| 102 | |||
| 103 | export function gitResolvePullRequestQueryOptions(input: { | ||
| 104 | environmentId: EnvironmentId | null; | ||
| 105 | cwd: string | null; | ||
| 106 | reference: string | null; | ||
| 107 | }) { | ||
| 108 | return queryOptions({ | ||
| 109 | queryKey: [ | ||
| 110 | "git", | ||
| 111 | "pull-request", | ||
| 112 | input.environmentId ?? null, | ||
| 113 | input.cwd, | ||
| 114 | input.reference, | ||
| 115 | ] as const, | ||
| 116 | queryFn: async () => { | ||
| 117 | if (!input.cwd || !input.reference || !input.environmentId) { | ||
| 118 | throw new Error("Pull request lookup is unavailable."); | ||
| 119 | } | ||
| 120 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 121 | return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference }); | ||
| 122 | }, | ||
| 123 | enabled: input.environmentId !== null && input.cwd !== null && input.reference !== null, | ||
| 124 | staleTime: 30_000, | ||
| 125 | refetchOnWindowFocus: false, | ||
| 126 | refetchOnReconnect: false, | ||
| 127 | }); | ||
| 128 | } | ||
| 129 | |||
| 130 | /** | ||
| 131 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | ||
| 132 | */ | ||
| 133 | export function gitInitMutationOptions(input: { | ||
| 134 | environmentId: EnvironmentId | null; | ||
| 135 | cwd: string | null; | ||
| 136 | queryClient: QueryClient; | ||
| 137 | }) { | ||
| 138 | return mutationOptions({ | ||
| 139 | mutationKey: gitMutationKeys.init(input.environmentId, input.cwd), | ||
| 140 | mutationFn: async () => { | ||
| 141 | if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable."); | ||
| 142 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 143 | return api.vcs.init({ cwd: input.cwd }); | ||
| 144 | }, | ||
| 145 | onSettled: async () => { | ||
| 146 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | ||
| 147 | }, | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | |||
| 151 | /** | ||
| 152 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | ||
| 153 | */ | ||
| 154 | export function gitCheckoutMutationOptions(input: { | ||
| 155 | environmentId: EnvironmentId | null; | ||
| 156 | cwd: string | null; | ||
| 157 | queryClient: QueryClient; | ||
| 158 | }) { | ||
| 159 | return mutationOptions({ | ||
| 160 | mutationKey: gitMutationKeys.switchRef(input.environmentId, input.cwd), | ||
| 161 | mutationFn: async (refName: string) => { | ||
| 162 | if (!input.cwd || !input.environmentId) throw new Error("Git switchRef is unavailable."); | ||
| 163 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 164 | return api.vcs.switchRef({ cwd: input.cwd, refName }); | ||
| 165 | }, | ||
| 166 | onSettled: async () => { | ||
| 167 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | ||
| 168 | }, | ||
| 169 | }); | ||
| 170 | } | ||
| 171 | |||
| 172 | export function gitRunStackedActionMutationOptions(input: { | ||
| 173 | environmentId: EnvironmentId | null; | ||
| 174 | cwd: string | null; | ||
| 175 | queryClient: QueryClient; | ||
| 176 | }) { | ||
| 177 | return mutationOptions({ | ||
| 178 | mutationKey: gitMutationKeys.runStackedAction(input.environmentId, input.cwd), | ||
| 179 | mutationFn: async ({ | ||
| 180 | actionId, | ||
| 181 | action, | ||
| 182 | commitMessage, | ||
| 183 | featureBranch, | ||
| 184 | filePaths, | ||
| 185 | onProgress, | ||
| 186 | }: { | ||
| 187 | actionId: string; | ||
| 188 | action: GitStackedAction; | ||
| 189 | commitMessage?: string; | ||
| 190 | featureBranch?: boolean; | ||
| 191 | filePaths?: string[]; | ||
| 192 | onProgress?: (event: GitActionProgressEvent) => void; | ||
| 193 | }) => { | ||
| 194 | if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable."); | ||
| 195 | return requireEnvironmentConnection(input.environmentId).client.git.runStackedAction( | ||
| 196 | { | ||
| 197 | action, | ||
| 198 | actionId, | ||
| 199 | cwd: input.cwd, | ||
| 200 | ...(commitMessage ? { commitMessage } : {}), | ||
| 201 | ...(featureBranch ? { featureBranch: true } : {}), | ||
| 202 | ...(filePaths && filePaths.length > 0 ? { filePaths } : {}), | ||
| 203 | }, | ||
| 204 | ...(onProgress ? [{ onProgress }] : []), | ||
| 205 | ); | ||
| 206 | }, | ||
| 207 | onSuccess: async () => { | ||
| 208 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | ||
| 209 | }, | ||
| 210 | }); | ||
| 211 | } | ||
| 212 | |||
| 213 | /** | ||
| 214 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | ||
| 215 | */ | ||
| 216 | export function gitPullMutationOptions(input: { | ||
| 217 | environmentId: EnvironmentId | null; | ||
| 218 | cwd: string | null; | ||
| 219 | queryClient: QueryClient; | ||
| 220 | }) { | ||
| 221 | return mutationOptions({ | ||
| 222 | mutationKey: gitMutationKeys.pull(input.environmentId, input.cwd), | ||
| 223 | mutationFn: async () => { | ||
| 224 | if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable."); | ||
| 225 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 226 | return api.vcs.pull({ cwd: input.cwd }); | ||
| 227 | }, | ||
| 228 | onSuccess: async () => { | ||
| 229 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | ||
| 230 | }, | ||
| 231 | }); | ||
| 232 | } | ||
| 233 | |||
| 234 | export function sourceControlPublishRepositoryMutationOptions(input: { | ||
| 235 | environmentId: EnvironmentId | null; | ||
| 236 | cwd: string | null; | ||
| 237 | queryClient: QueryClient; | ||
| 238 | }) { | ||
| 239 | return mutationOptions({ | ||
| 240 | mutationKey: gitMutationKeys.publishRepository(input.environmentId, input.cwd), | ||
| 241 | mutationFn: async (args: Omit<SourceControlPublishRepositoryInput, "cwd">) => { | ||
| 242 | if (!input.cwd || !input.environmentId) { | ||
| 243 | throw new Error("Repository publishing is unavailable."); | ||
| 244 | } | ||
| 245 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 246 | return api.sourceControl.publishRepository({ cwd: input.cwd, ...args }); | ||
| 247 | }, | ||
| 248 | onSuccess: async () => { | ||
| 249 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | ||
| 250 | }, | ||
| 251 | }); | ||
| 252 | } | ||
| 253 | |||
| 254 | /** | ||
| 255 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | ||
| 256 | */ | ||
| 257 | export function gitCreateWorktreeMutationOptions(input: { | ||
| 258 | environmentId: EnvironmentId | null; | ||
| 259 | queryClient: QueryClient; | ||
| 260 | }) { | ||
| 261 | return mutationOptions({ | ||
| 262 | mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const, | ||
| 263 | mutationFn: ( | ||
| 264 | args: Parameters<ReturnType<typeof ensureEnvironmentApi>["vcs"]["createWorktree"]>[0], | ||
| 265 | ) => { | ||
| 266 | if (!input.environmentId) { | ||
| 267 | throw new Error("Worktree creation is unavailable."); | ||
| 268 | } | ||
| 269 | return ensureEnvironmentApi(input.environmentId).vcs.createWorktree(args); | ||
| 270 | }, | ||
| 271 | onSuccess: async () => { | ||
| 272 | await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); | ||
| 273 | }, | ||
| 274 | }); | ||
| 275 | } | ||
| 276 | |||
| 277 | /** | ||
| 278 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | ||
| 279 | */ | ||
| 280 | export function gitRemoveWorktreeMutationOptions(input: { | ||
| 281 | environmentId: EnvironmentId | null; | ||
| 282 | queryClient: QueryClient; | ||
| 283 | }) { | ||
| 284 | return mutationOptions({ | ||
| 285 | mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const, | ||
| 286 | mutationFn: ( | ||
| 287 | args: Parameters<ReturnType<typeof ensureEnvironmentApi>["vcs"]["removeWorktree"]>[0], | ||
| 288 | ) => { | ||
| 289 | if (!input.environmentId) { | ||
| 290 | throw new Error("Worktree removal is unavailable."); | ||
| 291 | } | ||
| 292 | return ensureEnvironmentApi(input.environmentId).vcs.removeWorktree(args); | ||
| 293 | }, | ||
| 294 | onSuccess: async () => { | ||
| 295 | await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); | ||
| 296 | }, | ||
| 297 | }); | ||
| 298 | } | ||
| 299 | |||
| 300 | export function gitPreparePullRequestThreadMutationOptions(input: { | ||
| 301 | environmentId: EnvironmentId | null; | ||
| 302 | cwd: string | null; | ||
| 303 | queryClient: QueryClient; | ||
| 304 | }) { | ||
| 305 | return mutationOptions({ | ||
| 306 | mutationKey: gitMutationKeys.preparePullRequestThread(input.environmentId, input.cwd), | ||
| 307 | mutationFn: async (args: { | ||
| 308 | reference: string; | ||
| 309 | mode: "local" | "worktree"; | ||
| 310 | threadId?: ThreadId; | ||
| 311 | }) => { | ||
| 312 | if (!input.cwd || !input.environmentId) { | ||
| 313 | throw new Error("Pull request thread preparation is unavailable."); | ||
| 314 | } | ||
| 315 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 316 | return api.git.preparePullRequestThread({ | ||
| 317 | cwd: input.cwd, | ||
| 318 | reference: args.reference, | ||
| 319 | mode: args.mode, | ||
| 320 | ...(args.threadId ? { threadId: args.threadId } : {}), | ||
| 321 | }); | ||
| 322 | }, | ||
| 323 | onSuccess: async () => { | ||
| 324 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | ||
| 325 | }, | ||
| 326 | }); | ||
| 327 | } | ||
apps/web/src/lib/gitStatusState.test.ts deleted-327| ... | @@ -1,327 +0,0 @@ | ||
| 1 | import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts"; | ||
| 2 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 3 | |||
| 4 | import type { WsRpcClient } from "../rpc/wsRpcClient"; | ||
| 5 | import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry"; | ||
| 6 | import { | ||
| 7 | getGitStatusSnapshot, | ||
| 8 | resetGitStatusStateForTests, | ||
| 9 | refreshGitStatus, | ||
| 10 | watchGitStatus, | ||
| 11 | } from "./gitStatusState"; | ||
| 12 | |||
| 13 | const serviceHarness = vi.hoisted(() => ({ | ||
| 14 | connections: new Map<string, any>(), | ||
| 15 | listeners: new Set<() => void>(), | ||
| 16 | })); | ||
| 17 | |||
| 18 | vi.mock("../environments/runtime/service", () => ({ | ||
| 19 | readEnvironmentConnection: (environmentId: string) => | ||
| 20 | serviceHarness.connections.get(environmentId) ?? null, | ||
| 21 | subscribeEnvironmentConnections: (listener: () => void) => { | ||
| 22 | serviceHarness.listeners.add(listener); | ||
| 23 | return () => { | ||
| 24 | serviceHarness.listeners.delete(listener); | ||
| 25 | }; | ||
| 26 | }, | ||
| 27 | })); | ||
| 28 | |||
| 29 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | ||
| 30 | listeners.add(listener); | ||
| 31 | return () => { | ||
| 32 | listeners.delete(listener); | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); | ||
| 37 | const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | ||
| 38 | const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); | ||
| 39 | const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; | ||
| 40 | const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; | ||
| 41 | |||
| 42 | const BASE_STATUS: VcsStatusResult = { | ||
| 43 | isRepo: true, | ||
| 44 | hasPrimaryRemote: true, | ||
| 45 | isDefaultRef: false, | ||
| 46 | refName: "feature/push-status", | ||
| 47 | hasWorkingTreeChanges: false, | ||
| 48 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 49 | hasUpstream: true, | ||
| 50 | aheadCount: 0, | ||
| 51 | behindCount: 0, | ||
| 52 | pr: null, | ||
| 53 | }; | ||
| 54 | |||
| 55 | const gitClient = { | ||
| 56 | refreshStatus: vi.fn(async (input: { cwd: string }) => ({ | ||
| 57 | ...BASE_STATUS, | ||
| 58 | refName: `${input.cwd}-refreshed`, | ||
| 59 | })), | ||
| 60 | onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => | ||
| 61 | registerListener(gitStatusListeners, listener), | ||
| 62 | ), | ||
| 63 | }; | ||
| 64 | |||
| 65 | function emitGitStatus(event: VcsStatusResult) { | ||
| 66 | for (const listener of gitStatusListeners) { | ||
| 67 | listener(event); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | function createRegisteredGitStatusClient(environmentId: EnvironmentId) { | ||
| 72 | const listeners = new Set<(event: VcsStatusResult) => void>(); | ||
| 73 | const client = { | ||
| 74 | dispose: vi.fn(async () => undefined), | ||
| 75 | reconnect: vi.fn(async () => undefined), | ||
| 76 | terminal: { | ||
| 77 | open: vi.fn(async () => undefined), | ||
| 78 | write: vi.fn(async () => undefined), | ||
| 79 | resize: vi.fn(async () => undefined), | ||
| 80 | clear: vi.fn(async () => undefined), | ||
| 81 | restart: vi.fn(async () => undefined), | ||
| 82 | close: vi.fn(async () => undefined), | ||
| 83 | onEvent: vi.fn(() => () => undefined), | ||
| 84 | }, | ||
| 85 | projects: { | ||
| 86 | searchEntries: vi.fn(async () => []), | ||
| 87 | writeFile: vi.fn(async () => undefined), | ||
| 88 | }, | ||
| 89 | shell: { | ||
| 90 | openInEditor: vi.fn(async () => undefined), | ||
| 91 | }, | ||
| 92 | vcs: { | ||
| 93 | pull: vi.fn(async () => undefined), | ||
| 94 | refreshStatus: vi.fn(async (input: { cwd: string }) => ({ | ||
| 95 | ...BASE_STATUS, | ||
| 96 | refName: `${input.cwd}-refreshed`, | ||
| 97 | })), | ||
| 98 | onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => | ||
| 99 | registerListener(listeners, listener), | ||
| 100 | ), | ||
| 101 | listRefs: vi.fn(async () => []), | ||
| 102 | createWorktree: vi.fn(async () => undefined), | ||
| 103 | removeWorktree: vi.fn(async () => undefined), | ||
| 104 | createRef: vi.fn(async () => undefined), | ||
| 105 | switchRef: vi.fn(async () => undefined), | ||
| 106 | init: vi.fn(async () => undefined), | ||
| 107 | }, | ||
| 108 | git: { | ||
| 109 | runStackedAction: vi.fn(async () => ({}) as any), | ||
| 110 | resolvePullRequest: vi.fn(async () => undefined), | ||
| 111 | preparePullRequestThread: vi.fn(async () => undefined), | ||
| 112 | }, | ||
| 113 | server: { | ||
| 114 | getConfig: vi.fn(async () => ({ | ||
| 115 | environment: { | ||
| 116 | environmentId, | ||
| 117 | }, | ||
| 118 | })), | ||
| 119 | refreshProviders: vi.fn(async () => undefined), | ||
| 120 | upsertKeybinding: vi.fn(async () => undefined), | ||
| 121 | getSettings: vi.fn(async () => undefined), | ||
| 122 | updateSettings: vi.fn(async () => undefined), | ||
| 123 | subscribeConfig: vi.fn(() => () => undefined), | ||
| 124 | subscribeLifecycle: vi.fn(() => () => undefined), | ||
| 125 | subscribeAuthAccess: vi.fn(() => () => undefined), | ||
| 126 | }, | ||
| 127 | orchestration: { | ||
| 128 | dispatchCommand: vi.fn(async () => undefined), | ||
| 129 | getTurnDiff: vi.fn(async () => undefined), | ||
| 130 | getFullThreadDiff: vi.fn(async () => undefined), | ||
| 131 | subscribeShell: vi.fn(() => () => undefined), | ||
| 132 | subscribeThread: vi.fn(() => () => undefined), | ||
| 133 | }, | ||
| 134 | } as unknown as WsRpcClient; | ||
| 135 | |||
| 136 | serviceHarness.connections.set(environmentId, { | ||
| 137 | kind: "saved" as const, | ||
| 138 | knownEnvironment: { | ||
| 139 | id: environmentId, | ||
| 140 | label: `Environment ${environmentId}`, | ||
| 141 | source: "manual" as const, | ||
| 142 | environmentId, | ||
| 143 | target: { | ||
| 144 | httpBaseUrl: "http://example.test", | ||
| 145 | wsBaseUrl: "ws://example.test", | ||
| 146 | }, | ||
| 147 | }, | ||
| 148 | client, | ||
| 149 | environmentId, | ||
| 150 | ensureBootstrapped: async () => undefined, | ||
| 151 | reconnect: async () => undefined, | ||
| 152 | dispose: async () => undefined, | ||
| 153 | }); | ||
| 154 | for (const listener of serviceHarness.listeners) { | ||
| 155 | listener(); | ||
| 156 | } | ||
| 157 | |||
| 158 | return { | ||
| 159 | client, | ||
| 160 | emit: (event: VcsStatusResult) => { | ||
| 161 | for (const listener of listeners) { | ||
| 162 | listener(event); | ||
| 163 | } | ||
| 164 | }, | ||
| 165 | }; | ||
| 166 | } | ||
| 167 | |||
| 168 | afterEach(async () => { | ||
| 169 | gitStatusListeners.clear(); | ||
| 170 | serviceHarness.connections.clear(); | ||
| 171 | serviceHarness.listeners.clear(); | ||
| 172 | gitClient.onStatus.mockClear(); | ||
| 173 | gitClient.refreshStatus.mockClear(); | ||
| 174 | resetGitStatusStateForTests(); | ||
| 175 | resetAppAtomRegistryForTests(); | ||
| 176 | }); | ||
| 177 | |||
| 178 | describe("gitStatusState", () => { | ||
| 179 | it("starts fresh cwd state in a pending state", () => { | ||
| 180 | expect(getGitStatusSnapshot(FRESH_TARGET)).toEqual({ | ||
| 181 | data: null, | ||
| 182 | error: null, | ||
| 183 | cause: null, | ||
| 184 | isPending: true, | ||
| 185 | }); | ||
| 186 | }); | ||
| 187 | |||
| 188 | it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => { | ||
| 189 | const releaseA = watchGitStatus(TARGET, gitClient); | ||
| 190 | const releaseB = watchGitStatus(TARGET, gitClient); | ||
| 191 | |||
| 192 | expect(gitClient.onStatus).toHaveBeenCalledOnce(); | ||
| 193 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | ||
| 194 | data: null, | ||
| 195 | error: null, | ||
| 196 | cause: null, | ||
| 197 | isPending: true, | ||
| 198 | }); | ||
| 199 | |||
| 200 | emitGitStatus(BASE_STATUS); | ||
| 201 | |||
| 202 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | ||
| 203 | data: BASE_STATUS, | ||
| 204 | error: null, | ||
| 205 | cause: null, | ||
| 206 | isPending: false, | ||
| 207 | }); | ||
| 208 | |||
| 209 | releaseA(); | ||
| 210 | expect(gitStatusListeners.size).toBe(1); | ||
| 211 | |||
| 212 | releaseB(); | ||
| 213 | expect(gitStatusListeners.size).toBe(0); | ||
| 214 | }); | ||
| 215 | |||
| 216 | it("refreshes git status through the unary RPC without restarting the stream", async () => { | ||
| 217 | const release = watchGitStatus(TARGET, gitClient); | ||
| 218 | |||
| 219 | emitGitStatus(BASE_STATUS); | ||
| 220 | const refreshed = await refreshGitStatus(TARGET, gitClient); | ||
| 221 | |||
| 222 | expect(gitClient.onStatus).toHaveBeenCalledOnce(); | ||
| 223 | expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); | ||
| 224 | expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" }); | ||
| 225 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | ||
| 226 | data: BASE_STATUS, | ||
| 227 | error: null, | ||
| 228 | cause: null, | ||
| 229 | isPending: false, | ||
| 230 | }); | ||
| 231 | |||
| 232 | release(); | ||
| 233 | }); | ||
| 234 | |||
| 235 | it("keeps git status subscriptions isolated by environment when cwds match", () => { | ||
| 236 | const localListeners = new Set<(event: VcsStatusResult) => void>(); | ||
| 237 | const remoteListeners = new Set<(event: VcsStatusResult) => void>(); | ||
| 238 | const localClient = { | ||
| 239 | refreshStatus: vi.fn(), | ||
| 240 | onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => | ||
| 241 | registerListener(localListeners, listener), | ||
| 242 | ), | ||
| 243 | }; | ||
| 244 | const remoteClient = { | ||
| 245 | refreshStatus: vi.fn(), | ||
| 246 | onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => | ||
| 247 | registerListener(remoteListeners, listener), | ||
| 248 | ), | ||
| 249 | }; | ||
| 250 | const remoteTarget = { environmentId: OTHER_ENVIRONMENT_ID, cwd: "/repo" } as const; | ||
| 251 | |||
| 252 | const releaseLocal = watchGitStatus(TARGET, localClient); | ||
| 253 | const releaseRemote = watchGitStatus(remoteTarget, remoteClient); | ||
| 254 | |||
| 255 | for (const listener of localListeners) { | ||
| 256 | listener(BASE_STATUS); | ||
| 257 | } | ||
| 258 | for (const listener of remoteListeners) { | ||
| 259 | listener({ ...BASE_STATUS, refName: "remote-refName" }); | ||
| 260 | } | ||
| 261 | |||
| 262 | expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status"); | ||
| 263 | expect(getGitStatusSnapshot(remoteTarget).data?.refName).toBe("remote-refName"); | ||
| 264 | |||
| 265 | releaseLocal(); | ||
| 266 | releaseRemote(); | ||
| 267 | }); | ||
| 268 | |||
| 269 | it("waits for a delayed environment client registration instead of throwing", () => { | ||
| 270 | const release = watchGitStatus(TARGET); | ||
| 271 | |||
| 272 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | ||
| 273 | data: null, | ||
| 274 | error: null, | ||
| 275 | cause: null, | ||
| 276 | isPending: true, | ||
| 277 | }); | ||
| 278 | |||
| 279 | const registered = createRegisteredGitStatusClient(ENVIRONMENT_ID); | ||
| 280 | registered.emit(BASE_STATUS); | ||
| 281 | |||
| 282 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | ||
| 283 | data: BASE_STATUS, | ||
| 284 | error: null, | ||
| 285 | cause: null, | ||
| 286 | isPending: false, | ||
| 287 | }); | ||
| 288 | |||
| 289 | release(); | ||
| 290 | }); | ||
| 291 | |||
| 292 | it("resubscribes after the environment client is removed and re-registered", async () => { | ||
| 293 | const firstClient = createRegisteredGitStatusClient(ENVIRONMENT_ID); | ||
| 294 | const release = watchGitStatus(TARGET); | ||
| 295 | |||
| 296 | firstClient.emit(BASE_STATUS); | ||
| 297 | expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status"); | ||
| 298 | |||
| 299 | serviceHarness.connections.delete(ENVIRONMENT_ID); | ||
| 300 | for (const listener of serviceHarness.listeners) { | ||
| 301 | listener(); | ||
| 302 | } | ||
| 303 | |||
| 304 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | ||
| 305 | data: BASE_STATUS, | ||
| 306 | error: null, | ||
| 307 | cause: null, | ||
| 308 | isPending: true, | ||
| 309 | }); | ||
| 310 | |||
| 311 | const secondClient = createRegisteredGitStatusClient(ENVIRONMENT_ID); | ||
| 312 | secondClient.emit({ ...BASE_STATUS, refName: "reconnected-refName" }); | ||
| 313 | |||
| 314 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | ||
| 315 | data: { ...BASE_STATUS, refName: "reconnected-refName" }, | ||
| 316 | error: null, | ||
| 317 | cause: null, | ||
| 318 | isPending: false, | ||
| 319 | }); | ||
| 320 | |||
| 321 | release(); | ||
| 322 | }); | ||
| 323 | |||
| 324 | it("returns the cached snapshot when refresh is requested before the client is registered", async () => { | ||
| 325 | await expect(refreshGitStatus(TARGET)).resolves.toBeNull(); | ||
| 326 | }); | ||
| 327 | }); | ||
apps/web/src/lib/gitStatusState.ts deleted-287| ... | @@ -1,287 +0,0 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type EnvironmentId, | ||
| 4 | type GitManagerServiceError, | ||
| 5 | type VcsStatusResult, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Cause from "effect/Cause"; | ||
| 8 | import { Atom } from "effect/unstable/reactivity"; | ||
| 9 | import { useEffect } from "react"; | ||
| 10 | |||
| 11 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 12 | import { | ||
| 13 | readEnvironmentConnection, | ||
| 14 | subscribeEnvironmentConnections, | ||
| 15 | } from "../environments/runtime"; | ||
| 16 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | ||
| 17 | |||
| 18 | interface GitStatusState { | ||
| 19 | readonly data: VcsStatusResult | null; | ||
| 20 | readonly error: GitManagerServiceError | null; | ||
| 21 | readonly cause: Cause.Cause<GitManagerServiceError> | null; | ||
| 22 | readonly isPending: boolean; | ||
| 23 | } | ||
| 24 | |||
| 25 | type GitStatusClient = Pick<WsRpcClient["vcs"], "onStatus" | "refreshStatus">; | ||
| 26 | interface ResolvedGitStatusClient { | ||
| 27 | readonly clientIdentity: string; | ||
| 28 | readonly client: GitStatusClient; | ||
| 29 | } | ||
| 30 | |||
| 31 | interface WatchedGitStatus { | ||
| 32 | refCount: number; | ||
| 33 | unsubscribe: () => void; | ||
| 34 | } | ||
| 35 | |||
| 36 | interface GitStatusTarget { | ||
| 37 | readonly environmentId: EnvironmentId | null; | ||
| 38 | readonly cwd: string | null; | ||
| 39 | } | ||
| 40 | |||
| 41 | const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({ | ||
| 42 | data: null, | ||
| 43 | error: null, | ||
| 44 | cause: null, | ||
| 45 | isPending: false, | ||
| 46 | }); | ||
| 47 | const INITIAL_GIT_STATUS_STATE = Object.freeze<GitStatusState>({ | ||
| 48 | ...EMPTY_GIT_STATUS_STATE, | ||
| 49 | isPending: true, | ||
| 50 | }); | ||
| 51 | const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( | ||
| 52 | Atom.keepAlive, | ||
| 53 | Atom.withLabel("git-status:null"), | ||
| 54 | ); | ||
| 55 | |||
| 56 | const NOOP: () => void = () => undefined; | ||
| 57 | const watchedGitStatuses = new Map<string, WatchedGitStatus>(); | ||
| 58 | const knownGitStatusKeys = new Set<string>(); | ||
| 59 | const gitStatusRefreshInFlight = new Map<string, Promise<VcsStatusResult>>(); | ||
| 60 | const gitStatusLastRefreshAtByKey = new Map<string, number>(); | ||
| 61 | |||
| 62 | const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; | ||
| 63 | |||
| 64 | const gitStatusStateAtom = Atom.family((key: string) => { | ||
| 65 | knownGitStatusKeys.add(key); | ||
| 66 | return Atom.make(INITIAL_GIT_STATUS_STATE).pipe( | ||
| 67 | Atom.keepAlive, | ||
| 68 | Atom.withLabel(`git-status:${key}`), | ||
| 69 | ); | ||
| 70 | }); | ||
| 71 | |||
| 72 | function getGitStatusTargetKey(target: GitStatusTarget): string | null { | ||
| 73 | if (target.environmentId === null || target.cwd === null) { | ||
| 74 | return null; | ||
| 75 | } | ||
| 76 | |||
| 77 | return `${target.environmentId}:${target.cwd}`; | ||
| 78 | } | ||
| 79 | |||
| 80 | function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatusClient | null { | ||
| 81 | if (target.environmentId === null) { | ||
| 82 | return null; | ||
| 83 | } | ||
| 84 | const connection = readEnvironmentConnection(target.environmentId); | ||
| 85 | return connection | ||
| 86 | ? { clientIdentity: connection.environmentId, client: connection.client.vcs } | ||
| 87 | : null; | ||
| 88 | } | ||
| 89 | |||
| 90 | export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState { | ||
| 91 | const targetKey = getGitStatusTargetKey(target); | ||
| 92 | if (targetKey === null) { | ||
| 93 | return EMPTY_GIT_STATUS_STATE; | ||
| 94 | } | ||
| 95 | |||
| 96 | return appAtomRegistry.get(gitStatusStateAtom(targetKey)); | ||
| 97 | } | ||
| 98 | |||
| 99 | export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient): () => void { | ||
| 100 | const targetKey = getGitStatusTargetKey(target); | ||
| 101 | if (targetKey === null) { | ||
| 102 | return NOOP; | ||
| 103 | } | ||
| 104 | |||
| 105 | const watched = watchedGitStatuses.get(targetKey); | ||
| 106 | if (watched) { | ||
| 107 | watched.refCount += 1; | ||
| 108 | return () => unwatchGitStatus(targetKey); | ||
| 109 | } | ||
| 110 | |||
| 111 | watchedGitStatuses.set(targetKey, { | ||
| 112 | refCount: 1, | ||
| 113 | unsubscribe: subscribeToGitStatusTarget(targetKey, target, client), | ||
| 114 | }); | ||
| 115 | |||
| 116 | return () => unwatchGitStatus(targetKey); | ||
| 117 | } | ||
| 118 | |||
| 119 | export function refreshGitStatus( | ||
| 120 | target: GitStatusTarget, | ||
| 121 | client?: GitStatusClient, | ||
| 122 | ): Promise<VcsStatusResult | null> { | ||
| 123 | const targetKey = getGitStatusTargetKey(target); | ||
| 124 | if (targetKey === null || target.cwd === null) { | ||
| 125 | return Promise.resolve(null); | ||
| 126 | } | ||
| 127 | |||
| 128 | const resolvedClient = client ?? readResolvedGitStatusClient(target)?.client; | ||
| 129 | if (!resolvedClient) { | ||
| 130 | return Promise.resolve(getGitStatusSnapshot(target).data); | ||
| 131 | } | ||
| 132 | |||
| 133 | const currentInFlight = gitStatusRefreshInFlight.get(targetKey); | ||
| 134 | if (currentInFlight) { | ||
| 135 | return currentInFlight; | ||
| 136 | } | ||
| 137 | |||
| 138 | const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0; | ||
| 139 | if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) { | ||
| 140 | return Promise.resolve(getGitStatusSnapshot(target).data); | ||
| 141 | } | ||
| 142 | |||
| 143 | gitStatusLastRefreshAtByKey.set(targetKey, Date.now()); | ||
| 144 | const refreshPromise = resolvedClient.refreshStatus({ cwd: target.cwd }).finally(() => { | ||
| 145 | gitStatusRefreshInFlight.delete(targetKey); | ||
| 146 | }); | ||
| 147 | gitStatusRefreshInFlight.set(targetKey, refreshPromise); | ||
| 148 | return refreshPromise; | ||
| 149 | } | ||
| 150 | |||
| 151 | export function resetGitStatusStateForTests(): void { | ||
| 152 | for (const watched of watchedGitStatuses.values()) { | ||
| 153 | watched.unsubscribe(); | ||
| 154 | } | ||
| 155 | watchedGitStatuses.clear(); | ||
| 156 | gitStatusRefreshInFlight.clear(); | ||
| 157 | gitStatusLastRefreshAtByKey.clear(); | ||
| 158 | |||
| 159 | for (const key of knownGitStatusKeys) { | ||
| 160 | appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE); | ||
| 161 | } | ||
| 162 | knownGitStatusKeys.clear(); | ||
| 163 | } | ||
| 164 | |||
| 165 | export function useGitStatus(target: GitStatusTarget): GitStatusState { | ||
| 166 | const targetKey = getGitStatusTargetKey(target); | ||
| 167 | useEffect( | ||
| 168 | () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }), | ||
| 169 | [target.environmentId, target.cwd], | ||
| 170 | ); | ||
| 171 | |||
| 172 | const state = useAtomValue( | ||
| 173 | targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM, | ||
| 174 | ); | ||
| 175 | return targetKey === null ? EMPTY_GIT_STATUS_STATE : state; | ||
| 176 | } | ||
| 177 | |||
| 178 | function unwatchGitStatus(targetKey: string): void { | ||
| 179 | const watched = watchedGitStatuses.get(targetKey); | ||
| 180 | if (!watched) { | ||
| 181 | return; | ||
| 182 | } | ||
| 183 | |||
| 184 | watched.refCount -= 1; | ||
| 185 | if (watched.refCount > 0) { | ||
| 186 | return; | ||
| 187 | } | ||
| 188 | |||
| 189 | watched.unsubscribe(); | ||
| 190 | watchedGitStatuses.delete(targetKey); | ||
| 191 | } | ||
| 192 | |||
| 193 | function subscribeToGitStatusTarget( | ||
| 194 | targetKey: string, | ||
| 195 | target: GitStatusTarget, | ||
| 196 | providedClient?: GitStatusClient, | ||
| 197 | ): () => void { | ||
| 198 | if (target.cwd === null) { | ||
| 199 | return NOOP; | ||
| 200 | } | ||
| 201 | |||
| 202 | const cwd = target.cwd; | ||
| 203 | let currentClientIdentity: string | null = null; | ||
| 204 | let currentUnsubscribe = NOOP; | ||
| 205 | |||
| 206 | const syncClientSubscription = () => { | ||
| 207 | const resolved = providedClient | ||
| 208 | ? { | ||
| 209 | clientIdentity: `provided:${targetKey}`, | ||
| 210 | client: providedClient, | ||
| 211 | } | ||
| 212 | : readResolvedGitStatusClient(target); | ||
| 213 | |||
| 214 | if (!resolved) { | ||
| 215 | if (currentClientIdentity !== null) { | ||
| 216 | currentUnsubscribe(); | ||
| 217 | currentUnsubscribe = NOOP; | ||
| 218 | currentClientIdentity = null; | ||
| 219 | } | ||
| 220 | markGitStatusPending(targetKey); | ||
| 221 | return; | ||
| 222 | } | ||
| 223 | |||
| 224 | if (currentClientIdentity === resolved.clientIdentity) { | ||
| 225 | return; | ||
| 226 | } | ||
| 227 | |||
| 228 | currentUnsubscribe(); | ||
| 229 | currentClientIdentity = resolved.clientIdentity; | ||
| 230 | currentUnsubscribe = subscribeToGitStatus(targetKey, cwd, resolved.client); | ||
| 231 | }; | ||
| 232 | |||
| 233 | const unsubscribeRegistry = providedClient | ||
| 234 | ? NOOP | ||
| 235 | : subscribeEnvironmentConnections(syncClientSubscription); | ||
| 236 | syncClientSubscription(); | ||
| 237 | |||
| 238 | return () => { | ||
| 239 | unsubscribeRegistry(); | ||
| 240 | currentUnsubscribe(); | ||
| 241 | }; | ||
| 242 | } | ||
| 243 | |||
| 244 | function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void { | ||
| 245 | markGitStatusPending(targetKey); | ||
| 246 | return client.onStatus( | ||
| 247 | { cwd }, | ||
| 248 | (status: VcsStatusResult) => { | ||
| 249 | appAtomRegistry.set(gitStatusStateAtom(targetKey), { | ||
| 250 | data: status, | ||
| 251 | error: null, | ||
| 252 | cause: null, | ||
| 253 | isPending: false, | ||
| 254 | }); | ||
| 255 | }, | ||
| 256 | { | ||
| 257 | onResubscribe: () => { | ||
| 258 | markGitStatusPending(targetKey); | ||
| 259 | }, | ||
| 260 | }, | ||
| 261 | ); | ||
| 262 | } | ||
| 263 | |||
| 264 | function markGitStatusPending(targetKey: string): void { | ||
| 265 | const atom = gitStatusStateAtom(targetKey); | ||
| 266 | const current = appAtomRegistry.get(atom); | ||
| 267 | const next = | ||
| 268 | current.data === null | ||
| 269 | ? INITIAL_GIT_STATUS_STATE | ||
| 270 | : { | ||
| 271 | ...current, | ||
| 272 | error: null, | ||
| 273 | cause: null, | ||
| 274 | isPending: true, | ||
| 275 | }; | ||
| 276 | |||
| 277 | if ( | ||
| 278 | current.data === next.data && | ||
| 279 | current.error === next.error && | ||
| 280 | current.cause === next.cause && | ||
| 281 | current.isPending === next.isPending | ||
| 282 | ) { | ||
| 283 | return; | ||
| 284 | } | ||
| 285 | |||
| 286 | appAtomRegistry.set(atom, next); | ||
| 287 | } | ||
apps/web/src/lib/projectPaths.ts+17-259| ... | @@ -1,259 +1,17 @@ | ... | @@ -1,259 +1,17 @@ |
| 1 | import { | 1 | export { |
| 2 | isExplicitRelativePath, | 2 | appendBrowsePathSegment, |
| 3 | isUncPath, | 3 | canNavigateUp, |
| 4 | isWindowsAbsolutePath, | 4 | ensureBrowseDirectoryPath, |
| 5 | isWindowsDrivePath, | 5 | findProjectByPath, |
| 6 | } from "@t3tools/shared/path"; | 6 | getBrowseDirectoryPath, |
| 7 | import { isWindowsPlatform } from "./utils"; | 7 | getBrowseLeafPathSegment, |
| 8 | 8 | getBrowseParentPath, | |
| 9 | function isRootPath(value: string): boolean { | 9 | hasTrailingPathSeparator, |
| 10 | return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); | 10 | inferProjectTitleFromPath, |
| 11 | } | 11 | isExplicitRelativeProjectPath, |
| 12 | 12 | isFilesystemBrowseQuery, | |
| 13 | function getAbsolutePathKind(value: string): "unix" | "windows" | null { | 13 | isUnsupportedWindowsProjectPath, |
| 14 | if (isWindowsDrivePath(value) || isUncPath(value)) { | 14 | normalizeProjectPathForComparison, |
| 15 | return "windows"; | 15 | normalizeProjectPathForDispatch, |
| 16 | } | 16 | resolveProjectPathForDispatch, |
| 17 | 17 | } from "@t3tools/client-runtime"; | |
| 18 | if (value.startsWith("/")) { | ||
| 19 | return "unix"; | ||
| 20 | } | ||
| 21 | |||
| 22 | return null; | ||
| 23 | } | ||
| 24 | |||
| 25 | function trimTrailingPathSeparators(value: string): string { | ||
| 26 | if (value.length === 0 || isRootPath(value)) { | ||
| 27 | return value; | ||
| 28 | } | ||
| 29 | |||
| 30 | const trimmed = | ||
| 31 | getAbsolutePathKind(value) === "unix" | ||
| 32 | ? value.replace(/\/+$/g, "") | ||
| 33 | : value.replace(/[\\/]+$/g, ""); | ||
| 34 | if (trimmed.length === 0) { | ||
| 35 | return value; | ||
| 36 | } | ||
| 37 | |||
| 38 | return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; | ||
| 39 | } | ||
| 40 | |||
| 41 | function preferredPathSeparator(value: string): "/" | "\\" { | ||
| 42 | const absolutePathKind = getAbsolutePathKind(value); | ||
| 43 | if (absolutePathKind === "windows") { | ||
| 44 | return "\\"; | ||
| 45 | } | ||
| 46 | if (absolutePathKind === "unix") { | ||
| 47 | return "/"; | ||
| 48 | } | ||
| 49 | |||
| 50 | return value.includes("\\") ? "\\" : "/"; | ||
| 51 | } | ||
| 52 | |||
| 53 | export function hasTrailingPathSeparator(value: string): boolean { | ||
| 54 | return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value); | ||
| 55 | } | ||
| 56 | |||
| 57 | export { isExplicitRelativePath as isExplicitRelativeProjectPath }; | ||
| 58 | |||
| 59 | function splitPathSegments(value: string, separator: "/" | "\\"): string[] { | ||
| 60 | return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean); | ||
| 61 | } | ||
| 62 | |||
| 63 | function getLastPathSeparatorIndex(value: string): number { | ||
| 64 | if (getAbsolutePathKind(value) === "unix") { | ||
| 65 | return value.lastIndexOf("/"); | ||
| 66 | } | ||
| 67 | |||
| 68 | return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); | ||
| 69 | } | ||
| 70 | |||
| 71 | function splitAbsolutePath(value: string): { | ||
| 72 | root: string; | ||
| 73 | separator: "/" | "\\"; | ||
| 74 | segments: string[]; | ||
| 75 | } | null { | ||
| 76 | if (isWindowsDrivePath(value)) { | ||
| 77 | const root = `${value.slice(0, 2)}\\`; | ||
| 78 | const segments = splitPathSegments(value.slice(root.length), "\\"); | ||
| 79 | return { root, separator: "\\", segments }; | ||
| 80 | } | ||
| 81 | if (isUncPath(value)) { | ||
| 82 | const segments = splitPathSegments(value, "\\"); | ||
| 83 | const [server, share, ...rest] = segments; | ||
| 84 | if (!server || !share) { | ||
| 85 | return null; | ||
| 86 | } | ||
| 87 | return { | ||
| 88 | root: `\\\\${server}\\${share}\\`, | ||
| 89 | separator: "\\", | ||
| 90 | segments: rest, | ||
| 91 | }; | ||
| 92 | } | ||
| 93 | if (value.startsWith("/")) { | ||
| 94 | return { | ||
| 95 | root: "/", | ||
| 96 | separator: "/", | ||
| 97 | segments: splitPathSegments(value.slice(1), "/"), | ||
| 98 | }; | ||
| 99 | } | ||
| 100 | return null; | ||
| 101 | } | ||
| 102 | |||
| 103 | export function isFilesystemBrowseQuery( | ||
| 104 | value: string, | ||
| 105 | platform = typeof navigator === "undefined" ? "" : navigator.platform, | ||
| 106 | ): boolean { | ||
| 107 | const allowWindowsPaths = isWindowsPlatform(platform); | ||
| 108 | return ( | ||
| 109 | value.startsWith("./") || | ||
| 110 | value.startsWith("../") || | ||
| 111 | value.startsWith(".\\") || | ||
| 112 | value.startsWith("..\\") || | ||
| 113 | value.startsWith("/") || | ||
| 114 | value.startsWith("~/") || | ||
| 115 | (allowWindowsPaths && isWindowsAbsolutePath(value)) | ||
| 116 | ); | ||
| 117 | } | ||
| 118 | |||
| 119 | export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean { | ||
| 120 | return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); | ||
| 121 | } | ||
| 122 | |||
| 123 | export function normalizeProjectPathForDispatch(value: string): string { | ||
| 124 | return trimTrailingPathSeparators(value.trim()); | ||
| 125 | } | ||
| 126 | |||
| 127 | export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { | ||
| 128 | const trimmedValue = value.trim(); | ||
| 129 | if (!isExplicitRelativePath(trimmedValue) || !cwd) { | ||
| 130 | return normalizeProjectPathForDispatch(trimmedValue); | ||
| 131 | } | ||
| 132 | |||
| 133 | const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd)); | ||
| 134 | if (!absoluteBase) { | ||
| 135 | return normalizeProjectPathForDispatch(trimmedValue); | ||
| 136 | } | ||
| 137 | |||
| 138 | const nextSegments = [...absoluteBase.segments]; | ||
| 139 | for (const segment of trimmedValue.split(/[\\/]+/)) { | ||
| 140 | if (segment.length === 0 || segment === ".") { | ||
| 141 | continue; | ||
| 142 | } | ||
| 143 | if (segment === "..") { | ||
| 144 | nextSegments.pop(); | ||
| 145 | continue; | ||
| 146 | } | ||
| 147 | nextSegments.push(segment); | ||
| 148 | } | ||
| 149 | |||
| 150 | const joinedPath = nextSegments.join(absoluteBase.separator); | ||
| 151 | if (joinedPath.length === 0) { | ||
| 152 | return normalizeProjectPathForDispatch(absoluteBase.root); | ||
| 153 | } | ||
| 154 | |||
| 155 | return normalizeProjectPathForDispatch(`${absoluteBase.root}${joinedPath}`); | ||
| 156 | } | ||
| 157 | |||
| 158 | export function normalizeProjectPathForComparison(value: string): string { | ||
| 159 | const normalized = normalizeProjectPathForDispatch(value); | ||
| 160 | if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { | ||
| 161 | return normalized.replaceAll("/", "\\").toLowerCase(); | ||
| 162 | } | ||
| 163 | return normalized; | ||
| 164 | } | ||
| 165 | |||
| 166 | export function findProjectByPath<T extends { cwd: string }>( | ||
| 167 | projects: ReadonlyArray<T>, | ||
| 168 | candidatePath: string, | ||
| 169 | ): T | undefined { | ||
| 170 | const normalizedCandidate = normalizeProjectPathForComparison(candidatePath); | ||
| 171 | if (normalizedCandidate.length === 0) { | ||
| 172 | return undefined; | ||
| 173 | } | ||
| 174 | |||
| 175 | return projects.find( | ||
| 176 | (project) => normalizeProjectPathForComparison(project.cwd) === normalizedCandidate, | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | |||
| 180 | export function inferProjectTitleFromPath(value: string): string { | ||
| 181 | const normalized = normalizeProjectPathForDispatch(value); | ||
| 182 | const absolutePath = splitAbsolutePath(normalized); | ||
| 183 | if (absolutePath) { | ||
| 184 | return absolutePath.segments.findLast(Boolean) ?? normalized; | ||
| 185 | } | ||
| 186 | |||
| 187 | const segments = normalized.split(/[/\\]/); | ||
| 188 | return segments.findLast(Boolean) ?? normalized; | ||
| 189 | } | ||
| 190 | |||
| 191 | export function appendBrowsePathSegment(currentPath: string, segment: string): string { | ||
| 192 | const separator = preferredPathSeparator(currentPath); | ||
| 193 | return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`; | ||
| 194 | } | ||
| 195 | |||
| 196 | export function getBrowseLeafPathSegment(currentPath: string): string { | ||
| 197 | const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); | ||
| 198 | return currentPath.slice(lastSeparatorIndex + 1); | ||
| 199 | } | ||
| 200 | |||
| 201 | export function getBrowseDirectoryPath(currentPath: string): string { | ||
| 202 | if (hasTrailingPathSeparator(currentPath)) { | ||
| 203 | return currentPath; | ||
| 204 | } | ||
| 205 | |||
| 206 | const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); | ||
| 207 | if (lastSeparatorIndex < 0) { | ||
| 208 | return currentPath; | ||
| 209 | } | ||
| 210 | |||
| 211 | return currentPath.slice(0, lastSeparatorIndex + 1); | ||
| 212 | } | ||
| 213 | |||
| 214 | export function ensureBrowseDirectoryPath(currentPath: string): string { | ||
| 215 | const trimmed = currentPath.trim(); | ||
| 216 | if (trimmed.length === 0) { | ||
| 217 | return trimmed; | ||
| 218 | } | ||
| 219 | |||
| 220 | if (hasTrailingPathSeparator(trimmed)) { | ||
| 221 | return trimmed; | ||
| 222 | } | ||
| 223 | |||
| 224 | return `${trimmed}${preferredPathSeparator(trimmed)}`; | ||
| 225 | } | ||
| 226 | |||
| 227 | export function getBrowseParentPath(currentPath: string): string | null { | ||
| 228 | const trimmed = trimTrailingPathSeparators(currentPath); | ||
| 229 | const absolutePath = splitAbsolutePath(trimmed); | ||
| 230 | if (absolutePath) { | ||
| 231 | if (absolutePath.segments.length === 0) { | ||
| 232 | return null; | ||
| 233 | } | ||
| 234 | |||
| 235 | if (absolutePath.segments.length === 1) { | ||
| 236 | return absolutePath.root; | ||
| 237 | } | ||
| 238 | |||
| 239 | const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator); | ||
| 240 | return `${absolutePath.root}${parentSegments}${absolutePath.separator}`; | ||
| 241 | } | ||
| 242 | |||
| 243 | const separator = preferredPathSeparator(currentPath); | ||
| 244 | const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed); | ||
| 245 | |||
| 246 | if (lastSeparatorIndex < 0) { | ||
| 247 | return null; | ||
| 248 | } | ||
| 249 | |||
| 250 | if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { | ||
| 251 | return `${trimmed.slice(0, 2)}${separator}`; | ||
| 252 | } | ||
| 253 | |||
| 254 | return trimmed.slice(0, lastSeparatorIndex + 1); | ||
| 255 | } | ||
| 256 | |||
| 257 | export function canNavigateUp(currentPath: string): boolean { | ||
| 258 | return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; | ||
| 259 | } |
apps/web/src/lib/projectReactQuery.ts deleted-52| ... | @@ -1,52 +0,0 @@ | ||
| 1 | import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; | ||
| 2 | import { queryOptions } from "@tanstack/react-query"; | ||
| 3 | import { ensureEnvironmentApi } from "~/environmentApi"; | ||
| 4 | |||
| 5 | export const projectQueryKeys = { | ||
| 6 | all: ["projects"] as const, | ||
| 7 | searchEntries: ( | ||
| 8 | environmentId: EnvironmentId | null, | ||
| 9 | cwd: string | null, | ||
| 10 | query: string, | ||
| 11 | limit: number, | ||
| 12 | ) => ["projects", "search-entries", environmentId ?? null, cwd, query, limit] as const, | ||
| 13 | }; | ||
| 14 | |||
| 15 | const DEFAULT_SEARCH_ENTRIES_LIMIT = 80; | ||
| 16 | const DEFAULT_SEARCH_ENTRIES_STALE_TIME = 15_000; | ||
| 17 | const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { | ||
| 18 | entries: [], | ||
| 19 | truncated: false, | ||
| 20 | }; | ||
| 21 | |||
| 22 | export function projectSearchEntriesQueryOptions(input: { | ||
| 23 | environmentId: EnvironmentId | null; | ||
| 24 | cwd: string | null; | ||
| 25 | query: string; | ||
| 26 | enabled?: boolean; | ||
| 27 | limit?: number; | ||
| 28 | staleTime?: number; | ||
| 29 | }) { | ||
| 30 | const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT; | ||
| 31 | return queryOptions({ | ||
| 32 | queryKey: projectQueryKeys.searchEntries(input.environmentId, input.cwd, input.query, limit), | ||
| 33 | queryFn: async () => { | ||
| 34 | if (!input.cwd || !input.environmentId) { | ||
| 35 | throw new Error("Workspace entry search is unavailable."); | ||
| 36 | } | ||
| 37 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 38 | return api.projects.searchEntries({ | ||
| 39 | cwd: input.cwd, | ||
| 40 | query: input.query, | ||
| 41 | limit, | ||
| 42 | }); | ||
| 43 | }, | ||
| 44 | enabled: | ||
| 45 | (input.enabled ?? true) && | ||
| 46 | input.environmentId !== null && | ||
| 47 | input.cwd !== null && | ||
| 48 | input.query.length > 0, | ||
| 49 | staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, | ||
| 50 | placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, | ||
| 51 | }); | ||
| 52 | } | ||
apps/web/src/lib/providerReactQuery.test.ts deleted-224| ... | @@ -1,224 +0,0 @@ | ||
| 1 | import { EnvironmentId, ThreadId, type EnvironmentApi } from "@t3tools/contracts"; | ||
| 2 | import { QueryClient } from "@tanstack/react-query"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 4 | import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; | ||
| 5 | import * as environmentApi from "../environmentApi"; | ||
| 6 | |||
| 7 | const threadId = ThreadId.make("thread-id"); | ||
| 8 | const environmentId = EnvironmentId.make("environment-local"); | ||
| 9 | |||
| 10 | function mockNativeApi(input: { | ||
| 11 | getTurnDiff: ReturnType<typeof vi.fn>; | ||
| 12 | getFullThreadDiff: ReturnType<typeof vi.fn>; | ||
| 13 | }) { | ||
| 14 | vi.spyOn(environmentApi, "ensureEnvironmentApi").mockReturnValue({ | ||
| 15 | orchestration: { | ||
| 16 | getTurnDiff: input.getTurnDiff, | ||
| 17 | getFullThreadDiff: input.getFullThreadDiff, | ||
| 18 | }, | ||
| 19 | } as unknown as EnvironmentApi); | ||
| 20 | } | ||
| 21 | |||
| 22 | afterEach(() => { | ||
| 23 | vi.restoreAllMocks(); | ||
| 24 | }); | ||
| 25 | |||
| 26 | describe("providerQueryKeys.checkpointDiff", () => { | ||
| 27 | it("includes cacheScope so reused turn counts do not collide", () => { | ||
| 28 | const baseInput = { | ||
| 29 | environmentId, | ||
| 30 | threadId, | ||
| 31 | fromTurnCount: 1, | ||
| 32 | toTurnCount: 2, | ||
| 33 | ignoreWhitespace: false, | ||
| 34 | } as const; | ||
| 35 | |||
| 36 | expect( | ||
| 37 | providerQueryKeys.checkpointDiff({ | ||
| 38 | ...baseInput, | ||
| 39 | cacheScope: "turn:old-turn", | ||
| 40 | }), | ||
| 41 | ).not.toEqual( | ||
| 42 | providerQueryKeys.checkpointDiff({ | ||
| 43 | ...baseInput, | ||
| 44 | cacheScope: "turn:new-turn", | ||
| 45 | }), | ||
| 46 | ); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("includes ignoreWhitespace so normal and whitespace-hidden diffs do not collide", () => { | ||
| 50 | const baseInput = { | ||
| 51 | environmentId, | ||
| 52 | threadId, | ||
| 53 | fromTurnCount: 1, | ||
| 54 | toTurnCount: 2, | ||
| 55 | cacheScope: "turn:abc", | ||
| 56 | } as const; | ||
| 57 | |||
| 58 | expect( | ||
| 59 | providerQueryKeys.checkpointDiff({ | ||
| 60 | ...baseInput, | ||
| 61 | ignoreWhitespace: false, | ||
| 62 | }), | ||
| 63 | ).not.toEqual( | ||
| 64 | providerQueryKeys.checkpointDiff({ | ||
| 65 | ...baseInput, | ||
| 66 | ignoreWhitespace: true, | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | }); | ||
| 70 | }); | ||
| 71 | |||
| 72 | describe("checkpointDiffQueryOptions", () => { | ||
| 73 | it("forwards checkpoint range to the provider API by default", async () => { | ||
| 74 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | ||
| 75 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | ||
| 76 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | ||
| 77 | |||
| 78 | const options = checkpointDiffQueryOptions({ | ||
| 79 | environmentId, | ||
| 80 | threadId, | ||
| 81 | fromTurnCount: 3, | ||
| 82 | toTurnCount: 4, | ||
| 83 | ignoreWhitespace: false, | ||
| 84 | cacheScope: "turn:abc", | ||
| 85 | }); | ||
| 86 | |||
| 87 | const queryClient = new QueryClient(); | ||
| 88 | await queryClient.fetchQuery(options); | ||
| 89 | |||
| 90 | expect(getTurnDiff).toHaveBeenCalledWith({ | ||
| 91 | threadId, | ||
| 92 | fromTurnCount: 3, | ||
| 93 | toTurnCount: 4, | ||
| 94 | ignoreWhitespace: false, | ||
| 95 | }); | ||
| 96 | expect(getFullThreadDiff).not.toHaveBeenCalled(); | ||
| 97 | }); | ||
| 98 | |||
| 99 | it("forwards whitespace-hidden checkpoint range to the provider API", async () => { | ||
| 100 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | ||
| 101 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | ||
| 102 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | ||
| 103 | |||
| 104 | const options = checkpointDiffQueryOptions({ | ||
| 105 | environmentId, | ||
| 106 | threadId, | ||
| 107 | fromTurnCount: 3, | ||
| 108 | toTurnCount: 4, | ||
| 109 | ignoreWhitespace: true, | ||
| 110 | cacheScope: "turn:abc", | ||
| 111 | }); | ||
| 112 | |||
| 113 | const queryClient = new QueryClient(); | ||
| 114 | await queryClient.fetchQuery(options); | ||
| 115 | |||
| 116 | expect(getTurnDiff).toHaveBeenCalledWith({ | ||
| 117 | threadId, | ||
| 118 | fromTurnCount: 3, | ||
| 119 | toTurnCount: 4, | ||
| 120 | ignoreWhitespace: true, | ||
| 121 | }); | ||
| 122 | expect(getFullThreadDiff).not.toHaveBeenCalled(); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("uses explicit full thread diff API when range starts from zero", async () => { | ||
| 126 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | ||
| 127 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | ||
| 128 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | ||
| 129 | |||
| 130 | const options = checkpointDiffQueryOptions({ | ||
| 131 | environmentId, | ||
| 132 | threadId, | ||
| 133 | fromTurnCount: 0, | ||
| 134 | toTurnCount: 2, | ||
| 135 | ignoreWhitespace: true, | ||
| 136 | cacheScope: "thread:all", | ||
| 137 | }); | ||
| 138 | |||
| 139 | const queryClient = new QueryClient(); | ||
| 140 | await queryClient.fetchQuery(options); | ||
| 141 | |||
| 142 | expect(getFullThreadDiff).toHaveBeenCalledWith({ | ||
| 143 | threadId, | ||
| 144 | toTurnCount: 2, | ||
| 145 | ignoreWhitespace: true, | ||
| 146 | }); | ||
| 147 | expect(getTurnDiff).not.toHaveBeenCalled(); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("fails fast on invalid range and does not call provider RPC", async () => { | ||
| 151 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | ||
| 152 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | ||
| 153 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | ||
| 154 | |||
| 155 | const options = checkpointDiffQueryOptions({ | ||
| 156 | environmentId, | ||
| 157 | threadId, | ||
| 158 | fromTurnCount: 4, | ||
| 159 | toTurnCount: 3, | ||
| 160 | ignoreWhitespace: false, | ||
| 161 | cacheScope: "turn:invalid", | ||
| 162 | }); | ||
| 163 | |||
| 164 | const queryClient = new QueryClient(); | ||
| 165 | |||
| 166 | await expect(queryClient.fetchQuery(options)).rejects.toThrow( | ||
| 167 | "Checkpoint diff is unavailable.", | ||
| 168 | ); | ||
| 169 | expect(getTurnDiff).not.toHaveBeenCalled(); | ||
| 170 | expect(getFullThreadDiff).not.toHaveBeenCalled(); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("retries checkpoint-not-ready errors longer than generic failures", () => { | ||
| 174 | const options = checkpointDiffQueryOptions({ | ||
| 175 | environmentId, | ||
| 176 | threadId, | ||
| 177 | fromTurnCount: 1, | ||
| 178 | toTurnCount: 2, | ||
| 179 | ignoreWhitespace: false, | ||
| 180 | cacheScope: "turn:abc", | ||
| 181 | }); | ||
| 182 | const retry = options.retry; | ||
| 183 | expect(typeof retry).toBe("function"); | ||
| 184 | if (typeof retry !== "function") { | ||
| 185 | throw new Error("Expected retry to be a function."); | ||
| 186 | } | ||
| 187 | |||
| 188 | expect(retry(1, new Error("Checkpoint turn count 2 exceeds current turn count 1."))).toBe(true); | ||
| 189 | expect( | ||
| 190 | retry(11, new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1.")), | ||
| 191 | ).toBe(true); | ||
| 192 | expect( | ||
| 193 | retry(12, new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1.")), | ||
| 194 | ).toBe(false); | ||
| 195 | expect(retry(2, new Error("Something else failed."))).toBe(true); | ||
| 196 | expect(retry(3, new Error("Something else failed."))).toBe(false); | ||
| 197 | }); | ||
| 198 | |||
| 199 | it("backs off longer for checkpoint-not-ready errors", () => { | ||
| 200 | const options = checkpointDiffQueryOptions({ | ||
| 201 | environmentId, | ||
| 202 | threadId, | ||
| 203 | fromTurnCount: 1, | ||
| 204 | toTurnCount: 2, | ||
| 205 | ignoreWhitespace: false, | ||
| 206 | cacheScope: "turn:abc", | ||
| 207 | }); | ||
| 208 | const retryDelay = options.retryDelay; | ||
| 209 | expect(typeof retryDelay).toBe("function"); | ||
| 210 | if (typeof retryDelay !== "function") { | ||
| 211 | throw new Error("Expected retryDelay to be a function."); | ||
| 212 | } | ||
| 213 | |||
| 214 | const checkpointDelay = retryDelay( | ||
| 215 | 4, | ||
| 216 | new Error("Checkpoint turn count 2 exceeds current turn count 1."), | ||
| 217 | ); | ||
| 218 | const genericDelay = retryDelay(4, new Error("Network failure")); | ||
| 219 | |||
| 220 | expect(typeof checkpointDelay).toBe("number"); | ||
| 221 | expect(typeof genericDelay).toBe("number"); | ||
| 222 | expect((checkpointDelay ?? 0) > (genericDelay ?? 0)).toBe(true); | ||
| 223 | }); | ||
| 224 | }); | ||
apps/web/src/lib/providerReactQuery.ts deleted-139| ... | @@ -1,139 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type EnvironmentId, | ||
| 3 | OrchestrationGetFullThreadDiffInput, | ||
| 4 | OrchestrationGetTurnDiffInput, | ||
| 5 | ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { queryOptions } from "@tanstack/react-query"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import { ensureEnvironmentApi } from "../environmentApi"; | ||
| 11 | |||
| 12 | const decodeFullThreadDiffInput = Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput); | ||
| 13 | const decodeTurnDiffInput = Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput); | ||
| 14 | |||
| 15 | interface CheckpointDiffQueryInput { | ||
| 16 | environmentId: EnvironmentId | null; | ||
| 17 | threadId: ThreadId | null; | ||
| 18 | fromTurnCount: number | null; | ||
| 19 | toTurnCount: number | null; | ||
| 20 | ignoreWhitespace: boolean; | ||
| 21 | cacheScope?: string | null; | ||
| 22 | enabled?: boolean; | ||
| 23 | } | ||
| 24 | |||
| 25 | export const providerQueryKeys = { | ||
| 26 | all: ["providers"] as const, | ||
| 27 | checkpointDiff: (input: CheckpointDiffQueryInput) => | ||
| 28 | [ | ||
| 29 | "providers", | ||
| 30 | "checkpointDiff", | ||
| 31 | input.environmentId ?? null, | ||
| 32 | input.threadId, | ||
| 33 | input.fromTurnCount, | ||
| 34 | input.toTurnCount, | ||
| 35 | input.ignoreWhitespace, | ||
| 36 | input.cacheScope ?? null, | ||
| 37 | ] as const, | ||
| 38 | }; | ||
| 39 | |||
| 40 | function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) { | ||
| 41 | if (input.fromTurnCount === 0) { | ||
| 42 | return decodeFullThreadDiffInput({ | ||
| 43 | threadId: input.threadId, | ||
| 44 | toTurnCount: input.toTurnCount, | ||
| 45 | ignoreWhitespace: input.ignoreWhitespace, | ||
| 46 | }).pipe(Option.map((fields) => ({ kind: "fullThreadDiff" as const, input: fields }))); | ||
| 47 | } | ||
| 48 | |||
| 49 | return decodeTurnDiffInput({ | ||
| 50 | threadId: input.threadId, | ||
| 51 | fromTurnCount: input.fromTurnCount, | ||
| 52 | toTurnCount: input.toTurnCount, | ||
| 53 | ignoreWhitespace: input.ignoreWhitespace, | ||
| 54 | }).pipe(Option.map((fields) => ({ kind: "turnDiff" as const, input: fields }))); | ||
| 55 | } | ||
| 56 | |||
| 57 | function asCheckpointErrorMessage(error: unknown): string { | ||
| 58 | if (error instanceof Error) { | ||
| 59 | return error.message; | ||
| 60 | } | ||
| 61 | if (typeof error === "string") { | ||
| 62 | return error; | ||
| 63 | } | ||
| 64 | return ""; | ||
| 65 | } | ||
| 66 | |||
| 67 | function normalizeCheckpointErrorMessage(error: unknown): string { | ||
| 68 | const message = asCheckpointErrorMessage(error).trim(); | ||
| 69 | if (message.length === 0) { | ||
| 70 | return "Failed to load checkpoint diff."; | ||
| 71 | } | ||
| 72 | |||
| 73 | const lower = message.toLowerCase(); | ||
| 74 | if (lower.includes("not a git repository")) { | ||
| 75 | return "Turn diffs are unavailable because this project is not a git repository."; | ||
| 76 | } | ||
| 77 | |||
| 78 | if ( | ||
| 79 | lower.includes("checkpoint unavailable for thread") || | ||
| 80 | lower.includes("checkpoint invariant violation") | ||
| 81 | ) { | ||
| 82 | const separatorIndex = message.indexOf(":"); | ||
| 83 | if (separatorIndex >= 0) { | ||
| 84 | const detail = message.slice(separatorIndex + 1).trim(); | ||
| 85 | if (detail.length > 0) { | ||
| 86 | return detail; | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | return message; | ||
| 92 | } | ||
| 93 | |||
| 94 | function isCheckpointTemporarilyUnavailable(error: unknown): boolean { | ||
| 95 | const message = asCheckpointErrorMessage(error).toLowerCase(); | ||
| 96 | return ( | ||
| 97 | message.includes("exceeds current turn count") || | ||
| 98 | message.includes("checkpoint is unavailable for turn") || | ||
| 99 | message.includes("filesystem checkpoint is unavailable") | ||
| 100 | ); | ||
| 101 | } | ||
| 102 | |||
| 103 | export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { | ||
| 104 | const decodedRequest = decodeCheckpointDiffRequest(input); | ||
| 105 | |||
| 106 | return queryOptions({ | ||
| 107 | queryKey: providerQueryKeys.checkpointDiff(input), | ||
| 108 | queryFn: async () => { | ||
| 109 | if (!input.environmentId || !input.threadId || decodedRequest._tag === "None") { | ||
| 110 | throw new Error("Checkpoint diff is unavailable."); | ||
| 111 | } | ||
| 112 | const api = ensureEnvironmentApi(input.environmentId); | ||
| 113 | try { | ||
| 114 | if (decodedRequest.value.kind === "fullThreadDiff") { | ||
| 115 | return await api.orchestration.getFullThreadDiff(decodedRequest.value.input); | ||
| 116 | } | ||
| 117 | return await api.orchestration.getTurnDiff(decodedRequest.value.input); | ||
| 118 | } catch (error) { | ||
| 119 | throw new Error(normalizeCheckpointErrorMessage(error), { cause: error }); | ||
| 120 | } | ||
| 121 | }, | ||
| 122 | enabled: | ||
| 123 | (input.enabled ?? true) && | ||
| 124 | !!input.environmentId && | ||
| 125 | !!input.threadId && | ||
| 126 | decodedRequest._tag === "Some", | ||
| 127 | staleTime: Infinity, | ||
| 128 | retry: (failureCount, error) => { | ||
| 129 | if (isCheckpointTemporarilyUnavailable(error)) { | ||
| 130 | return failureCount < 12; | ||
| 131 | } | ||
| 132 | return failureCount < 3; | ||
| 133 | }, | ||
| 134 | retryDelay: (attempt, error) => | ||
| 135 | isCheckpointTemporarilyUnavailable(error) | ||
| 136 | ? Math.min(5_000, 250 * 2 ** (attempt - 1)) | ||
| 137 | : Math.min(1_000, 100 * 2 ** (attempt - 1)), | ||
| 138 | }); | ||
| 139 | } | ||
apps/web/src/lib/runtime.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import * as ManagedRuntime from "effect/ManagedRuntime"; | ||
| 2 | |||
| 3 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | ||
| 4 | |||
| 5 | export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch)); | ||
apps/web/src/lib/sourceControlActions.ts created+477| ... | @@ -0,0 +1,477 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type VcsActionOperation, | ||
| 4 | type VcsActionState, | ||
| 5 | EMPTY_VCS_ACTION_ATOM, | ||
| 6 | EMPTY_VCS_ACTION_STATE, | ||
| 7 | createVcsActionManager, | ||
| 8 | getVcsActionTargetKey, | ||
| 9 | vcsActionStateAtom, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { | ||
| 12 | type EnvironmentId, | ||
| 13 | type GitActionProgressEvent, | ||
| 14 | type GitRunStackedActionResult, | ||
| 15 | type GitStackedAction, | ||
| 16 | type GitResolvePullRequestResult, | ||
| 17 | type SourceControlCloneProtocol, | ||
| 18 | type SourceControlPublishRepositoryResult, | ||
| 19 | type SourceControlRepositoryVisibility, | ||
| 20 | type ThreadId, | ||
| 21 | type VcsPullResult, | ||
| 22 | } from "@t3tools/contracts"; | ||
| 23 | import { | ||
| 24 | useCallback, | ||
| 25 | useEffect, | ||
| 26 | useMemo, | ||
| 27 | useState, | ||
| 28 | useSyncExternalStore, | ||
| 29 | useTransition, | ||
| 30 | } from "react"; | ||
| 31 | |||
| 32 | import { ensureEnvironmentApi } from "../environmentApi"; | ||
| 33 | import { readEnvironmentConnection } from "../environments/runtime"; | ||
| 34 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 35 | import { getVcsStatusSnapshot, refreshVcsStatus } from "./vcsStatusState"; | ||
| 36 | import { vcsRefManager } from "./vcsRefState"; | ||
| 37 | |||
| 38 | type SourceControlActionKind = | ||
| 39 | | "init" | ||
| 40 | | "pull" | ||
| 41 | | "publishRepository" | ||
| 42 | | "runStackedAction" | ||
| 43 | | "preparePullRequestThread"; | ||
| 44 | |||
| 45 | interface SourceControlActionScope { | ||
| 46 | readonly environmentId: EnvironmentId | null; | ||
| 47 | readonly cwd: string | null; | ||
| 48 | } | ||
| 49 | |||
| 50 | interface SourceControlActionState<TArgs extends ReadonlyArray<unknown>, TResult> { | ||
| 51 | readonly isPending: boolean; | ||
| 52 | readonly error: unknown; | ||
| 53 | readonly run: (...args: TArgs) => Promise<TResult>; | ||
| 54 | readonly resetError: () => void; | ||
| 55 | } | ||
| 56 | |||
| 57 | export const vcsActionManager = createVcsActionManager({ | ||
| 58 | getRegistry: () => appAtomRegistry, | ||
| 59 | getClient: (environmentId) => { | ||
| 60 | const client = readEnvironmentConnection(environmentId)?.client; | ||
| 61 | return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; | ||
| 62 | }, | ||
| 63 | onInvalidate: (target) => invalidateSourceControlState(target), | ||
| 64 | }); | ||
| 65 | |||
| 66 | const actionListeners = new Set<() => void>(); | ||
| 67 | const activeActionCounts = new Map<string, number>(); | ||
| 68 | |||
| 69 | function notifyActionListeners(): void { | ||
| 70 | for (const listener of actionListeners) { | ||
| 71 | listener(); | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | function subscribeActionState(listener: () => void): () => void { | ||
| 76 | actionListeners.add(listener); | ||
| 77 | return () => { | ||
| 78 | actionListeners.delete(listener); | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | function actionKey(kind: SourceControlActionKind, scope: SourceControlActionScope): string { | ||
| 83 | return `${kind}:${scope.environmentId ?? ""}:${scope.cwd ?? ""}`; | ||
| 84 | } | ||
| 85 | |||
| 86 | function beginAction(key: string): () => void { | ||
| 87 | activeActionCounts.set(key, (activeActionCounts.get(key) ?? 0) + 1); | ||
| 88 | notifyActionListeners(); | ||
| 89 | let ended = false; | ||
| 90 | return () => { | ||
| 91 | if (ended) { | ||
| 92 | return; | ||
| 93 | } | ||
| 94 | ended = true; | ||
| 95 | const next = (activeActionCounts.get(key) ?? 1) - 1; | ||
| 96 | if (next <= 0) { | ||
| 97 | activeActionCounts.delete(key); | ||
| 98 | } else { | ||
| 99 | activeActionCounts.set(key, next); | ||
| 100 | } | ||
| 101 | notifyActionListeners(); | ||
| 102 | }; | ||
| 103 | } | ||
| 104 | |||
| 105 | function isAnyActionRunning( | ||
| 106 | kinds: ReadonlyArray<SourceControlActionKind>, | ||
| 107 | scope: SourceControlActionScope, | ||
| 108 | ): boolean { | ||
| 109 | return kinds.some((kind) => (activeActionCounts.get(actionKey(kind, scope)) ?? 0) > 0); | ||
| 110 | } | ||
| 111 | |||
| 112 | function getVcsActionOperationForKind(kind: SourceControlActionKind): VcsActionOperation | null { | ||
| 113 | switch (kind) { | ||
| 114 | case "init": | ||
| 115 | return "init"; | ||
| 116 | case "pull": | ||
| 117 | return "pull"; | ||
| 118 | case "runStackedAction": | ||
| 119 | return "run_change_request"; | ||
| 120 | case "publishRepository": | ||
| 121 | case "preparePullRequestThread": | ||
| 122 | return null; | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | function useVcsActionStateForScope(scope: SourceControlActionScope): VcsActionState { | ||
| 127 | const targetKey = getVcsActionTargetKey(scope); | ||
| 128 | const state = useAtomValue( | ||
| 129 | targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, | ||
| 130 | ); | ||
| 131 | return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; | ||
| 132 | } | ||
| 133 | |||
| 134 | export function invalidateSourceControlState(scope?: { | ||
| 135 | readonly environmentId?: EnvironmentId | null; | ||
| 136 | readonly cwd?: string | null; | ||
| 137 | }): Promise<void> { | ||
| 138 | const environmentId = scope?.environmentId ?? null; | ||
| 139 | const cwd = scope?.cwd ?? null; | ||
| 140 | if (cwd !== null) { | ||
| 141 | vcsRefManager.invalidateScope({ environmentId, cwd }); | ||
| 142 | if (environmentId !== null) { | ||
| 143 | return refreshVcsStatus({ environmentId, cwd }).then( | ||
| 144 | () => undefined, | ||
| 145 | () => undefined, | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | return Promise.resolve(); | ||
| 149 | } | ||
| 150 | |||
| 151 | vcsRefManager.invalidate(); | ||
| 152 | return Promise.resolve(); | ||
| 153 | } | ||
| 154 | |||
| 155 | function useSourceControlAction<TArgs extends ReadonlyArray<unknown>, TResult>(input: { | ||
| 156 | readonly kind: SourceControlActionKind; | ||
| 157 | readonly scope: SourceControlActionScope; | ||
| 158 | readonly action: (...args: TArgs) => Promise<TResult>; | ||
| 159 | readonly invalidateOnSuccess?: boolean; | ||
| 160 | }): SourceControlActionState<TArgs, TResult> { | ||
| 161 | const { action, invalidateOnSuccess = true, kind, scope } = input; | ||
| 162 | const [error, setError] = useState<unknown>(null); | ||
| 163 | const [activeCount, setActiveCount] = useState(0); | ||
| 164 | const [isTransitionPending, startTransition] = useTransition(); | ||
| 165 | const key = actionKey(kind, scope); | ||
| 166 | |||
| 167 | const resetError = useCallback(() => { | ||
| 168 | startTransition(() => setError(null)); | ||
| 169 | }, [startTransition]); | ||
| 170 | |||
| 171 | const run = useCallback( | ||
| 172 | async (...args: TArgs): Promise<TResult> => { | ||
| 173 | const endAction = beginAction(key); | ||
| 174 | startTransition(() => { | ||
| 175 | setError(null); | ||
| 176 | setActiveCount((count) => count + 1); | ||
| 177 | }); | ||
| 178 | try { | ||
| 179 | const result = await action(...args); | ||
| 180 | if (invalidateOnSuccess) { | ||
| 181 | await invalidateSourceControlState(scope); | ||
| 182 | } | ||
| 183 | return result; | ||
| 184 | } catch (nextError) { | ||
| 185 | startTransition(() => setError(nextError)); | ||
| 186 | throw nextError; | ||
| 187 | } finally { | ||
| 188 | endAction(); | ||
| 189 | startTransition(() => setActiveCount((count) => Math.max(0, count - 1))); | ||
| 190 | } | ||
| 191 | }, | ||
| 192 | [action, invalidateOnSuccess, key, scope, startTransition], | ||
| 193 | ); | ||
| 194 | |||
| 195 | return { | ||
| 196 | error, | ||
| 197 | isPending: activeCount > 0 || isTransitionPending, | ||
| 198 | resetError, | ||
| 199 | run, | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | |||
| 203 | export function useSourceControlActionRunning( | ||
| 204 | scope: SourceControlActionScope, | ||
| 205 | kinds: ReadonlyArray<SourceControlActionKind>, | ||
| 206 | ): boolean { | ||
| 207 | const stableKinds = useMemo(() => kinds.toSorted(), [kinds]); | ||
| 208 | const appActionRunning = useSyncExternalStore( | ||
| 209 | subscribeActionState, | ||
| 210 | () => isAnyActionRunning(stableKinds, scope), | ||
| 211 | () => false, | ||
| 212 | ); | ||
| 213 | const vcsActionState = useVcsActionStateForScope(scope); | ||
| 214 | const vcsActionRunning = | ||
| 215 | vcsActionState.isRunning && | ||
| 216 | stableKinds.some((kind) => getVcsActionOperationForKind(kind) === vcsActionState.operation); | ||
| 217 | |||
| 218 | return appActionRunning || vcsActionRunning; | ||
| 219 | } | ||
| 220 | |||
| 221 | function useVcsManagerAction<TArgs extends ReadonlyArray<unknown>, TResult>(input: { | ||
| 222 | readonly operation: VcsActionOperation; | ||
| 223 | readonly scope: SourceControlActionScope; | ||
| 224 | readonly unavailableMessage: string; | ||
| 225 | readonly action: (...args: TArgs) => Promise<TResult | null>; | ||
| 226 | }): SourceControlActionState<TArgs, TResult> { | ||
| 227 | const { action, operation, scope, unavailableMessage } = input; | ||
| 228 | const vcsActionState = useVcsActionStateForScope(scope); | ||
| 229 | const [error, setError] = useState<unknown>(null); | ||
| 230 | const [isTransitionPending, startTransition] = useTransition(); | ||
| 231 | |||
| 232 | const resetError = useCallback(() => { | ||
| 233 | vcsActionManager.reset(scope); | ||
| 234 | startTransition(() => setError(null)); | ||
| 235 | }, [scope, startTransition]); | ||
| 236 | |||
| 237 | const run = useCallback( | ||
| 238 | async (...args: TArgs): Promise<TResult> => { | ||
| 239 | startTransition(() => setError(null)); | ||
| 240 | try { | ||
| 241 | const result = await action(...args); | ||
| 242 | if (result === null) { | ||
| 243 | throw new Error(unavailableMessage); | ||
| 244 | } | ||
| 245 | return result; | ||
| 246 | } catch (nextError) { | ||
| 247 | startTransition(() => setError(nextError)); | ||
| 248 | throw nextError; | ||
| 249 | } | ||
| 250 | }, | ||
| 251 | [action, startTransition, unavailableMessage], | ||
| 252 | ); | ||
| 253 | |||
| 254 | return { | ||
| 255 | error: error ?? vcsActionState.error, | ||
| 256 | isPending: | ||
| 257 | isTransitionPending || (vcsActionState.isRunning && vcsActionState.operation === operation), | ||
| 258 | resetError, | ||
| 259 | run, | ||
| 260 | }; | ||
| 261 | } | ||
| 262 | |||
| 263 | export function useVcsInitAction(scope: SourceControlActionScope) { | ||
| 264 | const action = useCallback(async () => { | ||
| 265 | if (!scope.cwd || !scope.environmentId) throw new Error("Git init is unavailable."); | ||
| 266 | return vcsActionManager.init(scope); | ||
| 267 | }, [scope]); | ||
| 268 | |||
| 269 | return useVcsManagerAction({ | ||
| 270 | operation: "init", | ||
| 271 | scope, | ||
| 272 | unavailableMessage: "Git init is unavailable.", | ||
| 273 | action, | ||
| 274 | }); | ||
| 275 | } | ||
| 276 | |||
| 277 | export function useGitStackedAction(scope: SourceControlActionScope) { | ||
| 278 | const action = useCallback( | ||
| 279 | async ({ | ||
| 280 | actionId, | ||
| 281 | action, | ||
| 282 | commitMessage, | ||
| 283 | featureBranch, | ||
| 284 | filePaths, | ||
| 285 | onProgress, | ||
| 286 | }: { | ||
| 287 | actionId: string; | ||
| 288 | action: GitStackedAction; | ||
| 289 | commitMessage?: string; | ||
| 290 | featureBranch?: boolean; | ||
| 291 | filePaths?: string[]; | ||
| 292 | onProgress?: (event: GitActionProgressEvent) => void; | ||
| 293 | }): Promise<GitRunStackedActionResult | null> => { | ||
| 294 | if (!scope.cwd || !scope.environmentId) throw new Error("Git action is unavailable."); | ||
| 295 | return vcsActionManager.runChangeRequest( | ||
| 296 | scope, | ||
| 297 | { | ||
| 298 | actionId, | ||
| 299 | action, | ||
| 300 | ...(commitMessage ? { commitMessage } : {}), | ||
| 301 | ...(featureBranch ? { featureBranch: true } : {}), | ||
| 302 | ...(filePaths && filePaths.length > 0 ? { filePaths } : {}), | ||
| 303 | }, | ||
| 304 | { | ||
| 305 | gitStatus: getVcsStatusSnapshot(scope).data, | ||
| 306 | ...(onProgress ? { onProgress } : {}), | ||
| 307 | }, | ||
| 308 | ); | ||
| 309 | }, | ||
| 310 | [scope], | ||
| 311 | ); | ||
| 312 | |||
| 313 | return useVcsManagerAction({ | ||
| 314 | operation: "run_change_request", | ||
| 315 | scope, | ||
| 316 | unavailableMessage: "Git action is unavailable.", | ||
| 317 | action, | ||
| 318 | }); | ||
| 319 | } | ||
| 320 | |||
| 321 | export function useVcsPullAction(scope: SourceControlActionScope) { | ||
| 322 | const action = useCallback(async (): Promise<VcsPullResult | null> => { | ||
| 323 | if (!scope.cwd || !scope.environmentId) throw new Error("Git pull is unavailable."); | ||
| 324 | return vcsActionManager.pull(scope); | ||
| 325 | }, [scope]); | ||
| 326 | |||
| 327 | return useVcsManagerAction({ | ||
| 328 | operation: "pull", | ||
| 329 | scope, | ||
| 330 | unavailableMessage: "Git pull is unavailable.", | ||
| 331 | action, | ||
| 332 | }); | ||
| 333 | } | ||
| 334 | |||
| 335 | export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) { | ||
| 336 | const action = useCallback( | ||
| 337 | async (args: { | ||
| 338 | provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; | ||
| 339 | repository: string; | ||
| 340 | visibility: SourceControlRepositoryVisibility; | ||
| 341 | remoteName: string; | ||
| 342 | protocol: SourceControlCloneProtocol; | ||
| 343 | }): Promise<SourceControlPublishRepositoryResult> => { | ||
| 344 | if (!scope.cwd || !scope.environmentId) { | ||
| 345 | throw new Error("Repository publishing is unavailable."); | ||
| 346 | } | ||
| 347 | return ensureEnvironmentApi(scope.environmentId).sourceControl.publishRepository({ | ||
| 348 | cwd: scope.cwd, | ||
| 349 | ...args, | ||
| 350 | }); | ||
| 351 | }, | ||
| 352 | [scope], | ||
| 353 | ); | ||
| 354 | |||
| 355 | return useSourceControlAction({ | ||
| 356 | kind: "publishRepository", | ||
| 357 | scope, | ||
| 358 | action, | ||
| 359 | }); | ||
| 360 | } | ||
| 361 | |||
| 362 | export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) { | ||
| 363 | const action = useCallback( | ||
| 364 | async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => { | ||
| 365 | if (!scope.cwd || !scope.environmentId) { | ||
| 366 | throw new Error("Pull request thread preparation is unavailable."); | ||
| 367 | } | ||
| 368 | return ensureEnvironmentApi(scope.environmentId).git.preparePullRequestThread({ | ||
| 369 | cwd: scope.cwd, | ||
| 370 | reference: args.reference, | ||
| 371 | mode: args.mode, | ||
| 372 | ...(args.threadId ? { threadId: args.threadId } : {}), | ||
| 373 | }); | ||
| 374 | }, | ||
| 375 | [scope], | ||
| 376 | ); | ||
| 377 | |||
| 378 | return useSourceControlAction({ | ||
| 379 | kind: "preparePullRequestThread", | ||
| 380 | scope, | ||
| 381 | action, | ||
| 382 | }); | ||
| 383 | } | ||
| 384 | |||
| 385 | interface PullRequestResolutionTarget { | ||
| 386 | readonly environmentId: EnvironmentId | null; | ||
| 387 | readonly cwd: string | null; | ||
| 388 | readonly reference: string | null; | ||
| 389 | } | ||
| 390 | |||
| 391 | interface PullRequestResolutionState { | ||
| 392 | readonly data: GitResolvePullRequestResult | null; | ||
| 393 | readonly error: unknown; | ||
| 394 | readonly isPending: boolean; | ||
| 395 | readonly isFetching: boolean; | ||
| 396 | } | ||
| 397 | |||
| 398 | const EMPTY_PULL_REQUEST_RESOLUTION: PullRequestResolutionState = { | ||
| 399 | data: null, | ||
| 400 | error: null, | ||
| 401 | isPending: false, | ||
| 402 | isFetching: false, | ||
| 403 | }; | ||
| 404 | |||
| 405 | const pullRequestResolutionCache = new Map<string, GitResolvePullRequestResult>(); | ||
| 406 | |||
| 407 | function pullRequestResolutionKey(target: PullRequestResolutionTarget): string | null { | ||
| 408 | if (!target.environmentId || !target.cwd || !target.reference) { | ||
| 409 | return null; | ||
| 410 | } | ||
| 411 | return `${target.environmentId}:${target.cwd}:${target.reference}`; | ||
| 412 | } | ||
| 413 | |||
| 414 | export function readCachedPullRequestResolution( | ||
| 415 | target: PullRequestResolutionTarget, | ||
| 416 | ): GitResolvePullRequestResult | null { | ||
| 417 | const key = pullRequestResolutionKey(target); | ||
| 418 | return key ? (pullRequestResolutionCache.get(key) ?? null) : null; | ||
| 419 | } | ||
| 420 | |||
| 421 | export function usePullRequestResolution( | ||
| 422 | target: PullRequestResolutionTarget, | ||
| 423 | ): PullRequestResolutionState { | ||
| 424 | const stableTarget = useMemo( | ||
| 425 | () => ({ | ||
| 426 | environmentId: target.environmentId, | ||
| 427 | cwd: target.cwd, | ||
| 428 | reference: target.reference, | ||
| 429 | }), | ||
| 430 | [target.cwd, target.environmentId, target.reference], | ||
| 431 | ); | ||
| 432 | const key = pullRequestResolutionKey(stableTarget); | ||
| 433 | const [state, setState] = useState<PullRequestResolutionState>(() => { | ||
| 434 | const cached = readCachedPullRequestResolution(stableTarget); | ||
| 435 | return cached | ||
| 436 | ? { data: cached, error: null, isPending: false, isFetching: false } | ||
| 437 | : EMPTY_PULL_REQUEST_RESOLUTION; | ||
| 438 | }); | ||
| 439 | |||
| 440 | useEffect(() => { | ||
| 441 | if (!key || !stableTarget.environmentId || !stableTarget.cwd || !stableTarget.reference) { | ||
| 442 | setState(EMPTY_PULL_REQUEST_RESOLUTION); | ||
| 443 | return; | ||
| 444 | } | ||
| 445 | |||
| 446 | const cached = pullRequestResolutionCache.get(key) ?? null; | ||
| 447 | setState({ | ||
| 448 | data: cached, | ||
| 449 | error: null, | ||
| 450 | isPending: cached === null, | ||
| 451 | isFetching: true, | ||
| 452 | }); | ||
| 453 | |||
| 454 | let cancelled = false; | ||
| 455 | ensureEnvironmentApi(stableTarget.environmentId) | ||
| 456 | .git.resolvePullRequest({ cwd: stableTarget.cwd, reference: stableTarget.reference }) | ||
| 457 | .then((result) => { | ||
| 458 | if (cancelled) { | ||
| 459 | return; | ||
| 460 | } | ||
| 461 | pullRequestResolutionCache.set(key, result); | ||
| 462 | setState({ data: result, error: null, isPending: false, isFetching: false }); | ||
| 463 | }) | ||
| 464 | .catch((error: unknown) => { | ||
| 465 | if (cancelled) { | ||
| 466 | return; | ||
| 467 | } | ||
| 468 | setState({ data: cached, error, isPending: false, isFetching: false }); | ||
| 469 | }); | ||
| 470 | |||
| 471 | return () => { | ||
| 472 | cancelled = true; | ||
| 473 | }; | ||
| 474 | }, [key, stableTarget]); | ||
| 475 | |||
| 476 | return state; | ||
| 477 | } | ||
apps/web/src/lib/sourceControlDiscoveryState.ts+10-18| ... | @@ -7,11 +7,13 @@ import { | ... | @@ -7,11 +7,13 @@ import { |
| 7 | sourceControlDiscoveryStateAtom, | 7 | sourceControlDiscoveryStateAtom, |
| 8 | } from "@t3tools/client-runtime"; | 8 | } from "@t3tools/client-runtime"; |
| 9 | import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts"; | 9 | import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts"; |
| 10 | import * as Effect from "effect/Effect"; | 10 | import { useEffect } from "react"; |
| 11 | import { Atom } from "effect/unstable/reactivity"; | ||
| 12 | 11 | ||
| 13 | import { readPrimaryEnvironmentDescriptor } from "../environments/primary"; | 12 | import { readPrimaryEnvironmentDescriptor } from "../environments/primary"; |
| 14 | import { readEnvironmentConnection } from "../environments/runtime"; | 13 | import { |
| 14 | readEnvironmentConnection, | ||
| 15 | subscribeEnvironmentConnections, | ||
| 16 | } from "../environments/runtime"; | ||
| 15 | import { readLocalApi } from "../localApi"; | 17 | import { readLocalApi } from "../localApi"; |
| 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 18 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 17 | 19 | ||
| ... | @@ -35,7 +37,7 @@ function sourceControlDiscoveryTarget( | ... | @@ -35,7 +37,7 @@ function sourceControlDiscoveryTarget( |
| 35 | : { key: environmentId }; | 37 | : { key: environmentId }; |
| 36 | } | 38 | } |
| 37 | 39 | ||
| 38 | export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ | 40 | const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ |
| 39 | getRegistry: () => appAtomRegistry, | 41 | getRegistry: () => appAtomRegistry, |
| 40 | getClient: (key) => { | 42 | getClient: (key) => { |
| 41 | if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { | 43 | if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { |
| ... | @@ -59,21 +61,11 @@ export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager | ... | @@ -59,21 +61,11 @@ export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager |
| 59 | } | 61 | } |
| 60 | return null; | 62 | return null; |
| 61 | }, | 63 | }, |
| 64 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 65 | staleTimeMs: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, | ||
| 66 | idleTtlMs: SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS, | ||
| 62 | }); | 67 | }); |
| 63 | 68 | ||
| 64 | const sourceControlDiscoveryAutoRefreshAtom = Atom.family((targetKey: string) => | ||
| 65 | Atom.make(() => | ||
| 66 | Effect.promise(() => sourceControlDiscoveryManager.refresh({ key: targetKey })), | ||
| 67 | ).pipe( | ||
| 68 | Atom.swr({ | ||
| 69 | staleTime: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, | ||
| 70 | revalidateOnMount: true, | ||
| 71 | }), | ||
| 72 | Atom.setIdleTTL(SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS), | ||
| 73 | Atom.withLabel(`source-control-discovery:auto-refresh:${targetKey}`), | ||
| 74 | ), | ||
| 75 | ); | ||
| 76 | |||
| 77 | export function refreshSourceControlDiscovery( | 69 | export function refreshSourceControlDiscovery( |
| 78 | input?: SourceControlDiscoveryTargetInput, | 70 | input?: SourceControlDiscoveryTargetInput, |
| 79 | ): Promise<SourceControlDiscoveryResult | null> { | 71 | ): Promise<SourceControlDiscoveryResult | null> { |
| ... | @@ -97,7 +89,7 @@ export function useSourceControlDiscovery( | ... | @@ -97,7 +89,7 @@ export function useSourceControlDiscovery( |
| 97 | getSourceControlDiscoveryTargetKey(sourceControlDiscoveryTarget(input)) ?? | 89 | getSourceControlDiscoveryTargetKey(sourceControlDiscoveryTarget(input)) ?? |
| 98 | SOURCE_CONTROL_DISCOVERY_TARGET.key; | 90 | SOURCE_CONTROL_DISCOVERY_TARGET.key; |
| 99 | 91 | ||
| 100 | useAtomValue(sourceControlDiscoveryAutoRefreshAtom(targetKey)); | 92 | useEffect(() => sourceControlDiscoveryManager.watch({ key: targetKey }), [targetKey]); |
| 101 | 93 | ||
| 102 | return useAtomValue(sourceControlDiscoveryStateAtom(targetKey)); | 94 | return useAtomValue(sourceControlDiscoveryStateAtom(targetKey)); |
| 103 | } | 95 | } |
apps/web/src/lib/terminalFocus.test.ts+18-1| ... | @@ -11,7 +11,13 @@ class MockHTMLElement { | ... | @@ -11,7 +11,13 @@ class MockHTMLElement { |
| 11 | }; | 11 | }; |
| 12 | 12 | ||
| 13 | closest(selector: string): MockHTMLElement | null { | 13 | closest(selector: string): MockHTMLElement | null { |
| 14 | return selector === ".thread-terminal-drawer .xterm" && this.isConnected ? this : null; | 14 | if (!this.isConnected) { |
| 15 | return null; | ||
| 16 | } | ||
| 17 | if (selector === ".thread-terminal-drawer .xterm" || selector === ".thread-terminal-drawer") { | ||
| 18 | return this; | ||
| 19 | } | ||
| 20 | return null; | ||
| 15 | } | 21 | } |
| 16 | } | 22 | } |
| 17 | 23 | ||
| ... | @@ -53,4 +59,15 @@ describe("isTerminalFocused", () => { | ... | @@ -53,4 +59,15 @@ describe("isTerminalFocused", () => { |
| 53 | 59 | ||
| 54 | expect(isTerminalFocused()).toBe(true); | 60 | expect(isTerminalFocused()).toBe(true); |
| 55 | }); | 61 | }); |
| 62 | |||
| 63 | it("returns true for focus inside the terminal drawer (e.g. sidebar)", () => { | ||
| 64 | const sidebarButton = new MockHTMLElement(); | ||
| 65 | sidebarButton.className = "terminal-sidebar-button"; | ||
| 66 | sidebarButton.isConnected = true; | ||
| 67 | |||
| 68 | globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; | ||
| 69 | globalThis.document = { activeElement: sidebarButton } as Document; | ||
| 70 | |||
| 71 | expect(isTerminalFocused()).toBe(true); | ||
| 72 | }); | ||
| 56 | }); | 73 | }); |
apps/web/src/lib/terminalFocus.ts+3-1| ... | @@ -3,5 +3,7 @@ export function isTerminalFocused(): boolean { | ... | @@ -3,5 +3,7 @@ export function isTerminalFocused(): boolean { |
| 3 | if (!(activeElement instanceof HTMLElement)) return false; | 3 | if (!(activeElement instanceof HTMLElement)) return false; |
| 4 | if (!activeElement.isConnected) return false; | 4 | if (!activeElement.isConnected) return false; |
| 5 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; | 5 | if (activeElement.classList.contains("xterm-helper-textarea")) return true; |
| 6 | return activeElement.closest(".thread-terminal-drawer .xterm") !== null; | 6 | if (activeElement.closest(".thread-terminal-drawer .xterm") !== null) return true; |
| 7 | // Sidebar / toolbar / resize affordances: still "terminal UI" for split vs diff.toggle (⌘D). | ||
| 8 | return activeElement.closest(".thread-terminal-drawer") !== null; | ||
| 7 | } | 9 | } |
apps/web/src/lib/terminalStateCleanup.test.ts deleted-65| ... | @@ -1,65 +0,0 @@ | ||
| 1 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 2 | import { ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { describe, expect, it } from "vitest"; | ||
| 4 | |||
| 5 | import { collectActiveTerminalThreadIds } from "./terminalStateCleanup"; | ||
| 6 | |||
| 7 | const threadId = (id: string): ThreadId => ThreadId.make(id); | ||
| 8 | const threadKey = (environmentId: string, id: string): string => | ||
| 9 | scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id))); | ||
| 10 | |||
| 11 | describe("collectActiveTerminalThreadIds", () => { | ||
| 12 | it("retains non-deleted server threads", () => { | ||
| 13 | const activeThreadIds = collectActiveTerminalThreadIds({ | ||
| 14 | snapshotThreads: [ | ||
| 15 | { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null }, | ||
| 16 | { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null }, | ||
| 17 | ], | ||
| 18 | draftThreadKeys: [], | ||
| 19 | }); | ||
| 20 | |||
| 21 | expect(activeThreadIds).toEqual( | ||
| 22 | new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]), | ||
| 23 | ); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("ignores deleted and archived server threads and keeps local draft threads", () => { | ||
| 27 | const activeThreadIds = collectActiveTerminalThreadIds({ | ||
| 28 | snapshotThreads: [ | ||
| 29 | { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null }, | ||
| 30 | { | ||
| 31 | key: threadKey("env-a", "server-deleted"), | ||
| 32 | deletedAt: "2026-03-05T08:00:00.000Z", | ||
| 33 | archivedAt: null, | ||
| 34 | }, | ||
| 35 | { | ||
| 36 | key: threadKey("env-a", "server-archived"), | ||
| 37 | deletedAt: null, | ||
| 38 | archivedAt: "2026-03-05T09:00:00.000Z", | ||
| 39 | }, | ||
| 40 | ], | ||
| 41 | draftThreadKeys: [threadKey("env-a", "local-draft")], | ||
| 42 | }); | ||
| 43 | |||
| 44 | expect(activeThreadIds).toEqual( | ||
| 45 | new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]), | ||
| 46 | ); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("does not keep draft-linked terminal state for archived server threads", () => { | ||
| 50 | const archivedThreadId = threadKey("env-a", "server-archived"); | ||
| 51 | |||
| 52 | const activeThreadIds = collectActiveTerminalThreadIds({ | ||
| 53 | snapshotThreads: [ | ||
| 54 | { | ||
| 55 | key: archivedThreadId, | ||
| 56 | deletedAt: null, | ||
| 57 | archivedAt: "2026-03-05T09:00:00.000Z", | ||
| 58 | }, | ||
| 59 | ], | ||
| 60 | draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")], | ||
| 61 | }); | ||
| 62 | |||
| 63 | expect(activeThreadIds).toEqual(new Set([threadKey("env-a", "local-draft")])); | ||
| 64 | }); | ||
| 65 | }); | ||
apps/web/src/lib/terminalStateCleanup.ts deleted-33| ... | @@ -1,33 +0,0 @@ | ||
| 1 | interface TerminalRetentionThread { | ||
| 2 | key: string; | ||
| 3 | deletedAt: string | null; | ||
| 4 | archivedAt: string | null; | ||
| 5 | } | ||
| 6 | |||
| 7 | interface CollectActiveTerminalThreadIdsInput { | ||
| 8 | snapshotThreads: readonly TerminalRetentionThread[]; | ||
| 9 | draftThreadKeys: Iterable<string>; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function collectActiveTerminalThreadIds( | ||
| 13 | input: CollectActiveTerminalThreadIdsInput, | ||
| 14 | ): Set<string> { | ||
| 15 | const activeThreadIds = new Set<string>(); | ||
| 16 | const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread])); | ||
| 17 | for (const thread of input.snapshotThreads) { | ||
| 18 | if (thread.deletedAt !== null) continue; | ||
| 19 | if (thread.archivedAt !== null) continue; | ||
| 20 | activeThreadIds.add(thread.key); | ||
| 21 | } | ||
| 22 | for (const draftThreadKey of input.draftThreadKeys) { | ||
| 23 | const snapshotThread = snapshotThreadById.get(draftThreadKey); | ||
| 24 | if ( | ||
| 25 | snapshotThread && | ||
| 26 | (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) | ||
| 27 | ) { | ||
| 28 | continue; | ||
| 29 | } | ||
| 30 | activeThreadIds.add(draftThreadKey); | ||
| 31 | } | ||
| 32 | return activeThreadIds; | ||
| 33 | } | ||
apps/web/src/lib/terminalUiStateCleanup.test.ts created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | ||
| 2 | import { ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { describe, expect, it } from "vitest"; | ||
| 4 | |||
| 5 | import { collectActiveTerminalUiThreadKeys } from "./terminalUiStateCleanup"; | ||
| 6 | |||
| 7 | const threadId = (id: string): ThreadId => ThreadId.make(id); | ||
| 8 | const threadKey = (environmentId: string, id: string): string => | ||
| 9 | scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id))); | ||
| 10 | |||
| 11 | describe("collectActiveTerminalUiThreadKeys", () => { | ||
| 12 | it("retains non-deleted server threads", () => { | ||
| 13 | const activeThreadKeys = collectActiveTerminalUiThreadKeys({ | ||
| 14 | snapshotThreads: [ | ||
| 15 | { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null }, | ||
| 16 | { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null }, | ||
| 17 | ], | ||
| 18 | draftThreadKeys: [], | ||
| 19 | }); | ||
| 20 | |||
| 21 | expect(activeThreadKeys).toEqual( | ||
| 22 | new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]), | ||
| 23 | ); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("ignores deleted and archived server threads and keeps local draft threads", () => { | ||
| 27 | const activeThreadKeys = collectActiveTerminalUiThreadKeys({ | ||
| 28 | snapshotThreads: [ | ||
| 29 | { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null }, | ||
| 30 | { | ||
| 31 | key: threadKey("env-a", "server-deleted"), | ||
| 32 | deletedAt: "2026-03-05T08:00:00.000Z", | ||
| 33 | archivedAt: null, | ||
| 34 | }, | ||
| 35 | { | ||
| 36 | key: threadKey("env-a", "server-archived"), | ||
| 37 | deletedAt: null, | ||
| 38 | archivedAt: "2026-03-05T09:00:00.000Z", | ||
| 39 | }, | ||
| 40 | ], | ||
| 41 | draftThreadKeys: [threadKey("env-a", "local-draft")], | ||
| 42 | }); | ||
| 43 | |||
| 44 | expect(activeThreadKeys).toEqual( | ||
| 45 | new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]), | ||
| 46 | ); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("does not keep draft-linked terminal UI state for archived server threads", () => { | ||
| 50 | const archivedThreadId = threadKey("env-a", "server-archived"); | ||
| 51 | |||
| 52 | const activeThreadKeys = collectActiveTerminalUiThreadKeys({ | ||
| 53 | snapshotThreads: [ | ||
| 54 | { | ||
| 55 | key: archivedThreadId, | ||
| 56 | deletedAt: null, | ||
| 57 | archivedAt: "2026-03-05T09:00:00.000Z", | ||
| 58 | }, | ||
| 59 | ], | ||
| 60 | draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")], | ||
| 61 | }); | ||
| 62 | |||
| 63 | expect(activeThreadKeys).toEqual(new Set([threadKey("env-a", "local-draft")])); | ||
| 64 | }); | ||
| 65 | }); | ||
apps/web/src/lib/terminalUiStateCleanup.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | interface TerminalUiRetentionThread { | ||
| 2 | key: string; | ||
| 3 | deletedAt: string | null; | ||
| 4 | archivedAt: string | null; | ||
| 5 | } | ||
| 6 | |||
| 7 | interface CollectActiveTerminalUiThreadKeysInput { | ||
| 8 | snapshotThreads: readonly TerminalUiRetentionThread[]; | ||
| 9 | draftThreadKeys: Iterable<string>; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function collectActiveTerminalUiThreadKeys( | ||
| 13 | input: CollectActiveTerminalUiThreadKeysInput, | ||
| 14 | ): Set<string> { | ||
| 15 | const activeThreadKeys = new Set<string>(); | ||
| 16 | const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread])); | ||
| 17 | for (const thread of input.snapshotThreads) { | ||
| 18 | if (thread.deletedAt !== null) continue; | ||
| 19 | if (thread.archivedAt !== null) continue; | ||
| 20 | activeThreadKeys.add(thread.key); | ||
| 21 | } | ||
| 22 | for (const draftThreadKey of input.draftThreadKeys) { | ||
| 23 | const snapshotThread = snapshotThreadById.get(draftThreadKey); | ||
| 24 | if ( | ||
| 25 | snapshotThread && | ||
| 26 | (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) | ||
| 27 | ) { | ||
| 28 | continue; | ||
| 29 | } | ||
| 30 | activeThreadKeys.add(draftThreadKey); | ||
| 31 | } | ||
| 32 | return activeThreadKeys; | ||
| 33 | } | ||
apps/web/src/lib/vcsRefState.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type VcsRefState, | ||
| 4 | type VcsRefTarget, | ||
| 5 | EMPTY_VCS_REF_ATOM, | ||
| 6 | EMPTY_VCS_REF_STATE, | ||
| 7 | createVcsRefManager, | ||
| 8 | getVcsRefTargetKey, | ||
| 9 | vcsRefStateAtom, | ||
| 10 | } from "@t3tools/client-runtime"; | ||
| 11 | import { useEffect, useMemo } from "react"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | readEnvironmentConnection, | ||
| 15 | subscribeEnvironmentConnections, | ||
| 16 | } from "../environments/runtime"; | ||
| 17 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 18 | |||
| 19 | const VCS_REF_LIST_LIMIT = 100; | ||
| 20 | const VCS_REF_STALE_TIME_MS = 5_000; | ||
| 21 | |||
| 22 | export const vcsRefManager = createVcsRefManager({ | ||
| 23 | getRegistry: () => appAtomRegistry, | ||
| 24 | getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.vcs ?? null, | ||
| 25 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 26 | watchLimit: VCS_REF_LIST_LIMIT, | ||
| 27 | staleTimeMs: VCS_REF_STALE_TIME_MS, | ||
| 28 | onBackgroundError: (error) => { | ||
| 29 | console.warn("[vcs-refs] background refresh failed", error); | ||
| 30 | }, | ||
| 31 | }); | ||
| 32 | |||
| 33 | export function useVcsRefs(target: VcsRefTarget): VcsRefState { | ||
| 34 | const stableTarget = useMemo( | ||
| 35 | () => ({ | ||
| 36 | environmentId: target.environmentId, | ||
| 37 | cwd: target.cwd, | ||
| 38 | query: target.query ?? null, | ||
| 39 | }), | ||
| 40 | [target.cwd, target.environmentId, target.query], | ||
| 41 | ); | ||
| 42 | const targetKey = getVcsRefTargetKey(stableTarget); | ||
| 43 | |||
| 44 | useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); | ||
| 45 | |||
| 46 | const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); | ||
| 47 | return targetKey === null ? EMPTY_VCS_REF_STATE : state; | ||
| 48 | } | ||
apps/web/src/lib/vcsStatusState.ts created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | type VcsStatusClient, | ||
| 4 | type VcsStatusState, | ||
| 5 | type VcsStatusTarget, | ||
| 6 | EMPTY_VCS_STATUS_ATOM, | ||
| 7 | EMPTY_VCS_STATUS_STATE, | ||
| 8 | createVcsStatusManager, | ||
| 9 | getVcsStatusTargetKey, | ||
| 10 | vcsStatusStateAtom, | ||
| 11 | } from "@t3tools/client-runtime"; | ||
| 12 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 13 | import { useEffect } from "react"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | readEnvironmentConnection, | ||
| 17 | subscribeEnvironmentConnections, | ||
| 18 | } from "../environments/runtime"; | ||
| 19 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 20 | |||
| 21 | export type { VcsStatusState, VcsStatusTarget }; | ||
| 22 | |||
| 23 | const manager = createVcsStatusManager({ | ||
| 24 | getRegistry: () => appAtomRegistry, | ||
| 25 | getClient: (environmentId) => { | ||
| 26 | const connection = readEnvironmentConnection(environmentId as EnvironmentId); | ||
| 27 | return connection ? connection.client.vcs : null; | ||
| 28 | }, | ||
| 29 | getClientIdentity: (environmentId) => { | ||
| 30 | const connection = readEnvironmentConnection(environmentId as EnvironmentId); | ||
| 31 | return connection ? connection.environmentId : null; | ||
| 32 | }, | ||
| 33 | subscribeClientChanges: subscribeEnvironmentConnections, | ||
| 34 | }); | ||
| 35 | |||
| 36 | export function getVcsStatusSnapshot(target: VcsStatusTarget): VcsStatusState { | ||
| 37 | return manager.getSnapshot(target); | ||
| 38 | } | ||
| 39 | |||
| 40 | export function watchVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient): () => void { | ||
| 41 | return manager.watch(target, client); | ||
| 42 | } | ||
| 43 | |||
| 44 | export function refreshVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient) { | ||
| 45 | return manager.refresh(target, client); | ||
| 46 | } | ||
| 47 | |||
| 48 | export function resetVcsStatusStateForTests(): void { | ||
| 49 | manager.reset(); | ||
| 50 | } | ||
| 51 | |||
| 52 | export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { | ||
| 53 | const targetKey = getVcsStatusTargetKey(target); | ||
| 54 | useEffect( | ||
| 55 | () => manager.watch({ environmentId: target.environmentId, cwd: target.cwd }), | ||
| 56 | [target.environmentId, target.cwd], | ||
| 57 | ); | ||
| 58 | |||
| 59 | const state = useAtomValue( | ||
| 60 | targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, | ||
| 61 | ); | ||
| 62 | return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; | ||
| 63 | } | ||
apps/web/src/localApi.test.ts+43-13| ... | @@ -10,7 +10,8 @@ import { | ... | @@ -10,7 +10,8 @@ import { |
| 10 | ProviderInstanceId, | 10 | ProviderInstanceId, |
| 11 | type ServerConfig, | 11 | type ServerConfig, |
| 12 | type ServerProvider, | 12 | type ServerProvider, |
| 13 | type TerminalEvent, | 13 | type TerminalAttachStreamEvent, |
| 14 | type TerminalMetadataStreamEvent, | ||
| 14 | ThreadId, | 15 | ThreadId, |
| 15 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 16 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 17 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| ... | @@ -32,7 +33,8 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even | ... | @@ -32,7 +33,8 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even |
| 32 | }; | 33 | }; |
| 33 | } | 34 | } |
| 34 | 35 | ||
| 35 | const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); | 36 | const terminalAttachListeners = new Set<(event: TerminalAttachStreamEvent) => void>(); |
| 37 | const terminalMetadataListeners = new Set<(event: TerminalMetadataStreamEvent) => void>(); | ||
| 36 | const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); | 38 | const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); |
| 37 | const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); | 39 | const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); |
| 38 | 40 | ||
| ... | @@ -40,13 +42,16 @@ const rpcClientMock = { | ... | @@ -40,13 +42,16 @@ const rpcClientMock = { |
| 40 | dispose: vi.fn(), | 42 | dispose: vi.fn(), |
| 41 | terminal: { | 43 | terminal: { |
| 42 | open: vi.fn(), | 44 | open: vi.fn(), |
| 45 | attach: vi.fn((_input: unknown, listener: (event: TerminalAttachStreamEvent) => void) => | ||
| 46 | registerListener(terminalAttachListeners, listener), | ||
| 47 | ), | ||
| 43 | write: vi.fn(), | 48 | write: vi.fn(), |
| 44 | resize: vi.fn(), | 49 | resize: vi.fn(), |
| 45 | clear: vi.fn(), | 50 | clear: vi.fn(), |
| 46 | restart: vi.fn(), | 51 | restart: vi.fn(), |
| 47 | close: vi.fn(), | 52 | close: vi.fn(), |
| 48 | onEvent: vi.fn((listener: (event: TerminalEvent) => void) => | 53 | onMetadata: vi.fn((listener: (event: TerminalMetadataStreamEvent) => void) => |
| 49 | registerListener(terminalEventListeners, listener), | 54 | registerListener(terminalMetadataListeners, listener), |
| 50 | ), | 55 | ), |
| 51 | }, | 56 | }, |
| 52 | projects: { | 57 | projects: { |
| ... | @@ -82,6 +87,9 @@ const rpcClientMock = { | ... | @@ -82,6 +87,9 @@ const rpcClientMock = { |
| 82 | resolvePullRequest: vi.fn(), | 87 | resolvePullRequest: vi.fn(), |
| 83 | preparePullRequestThread: vi.fn(), | 88 | preparePullRequestThread: vi.fn(), |
| 84 | }, | 89 | }, |
| 90 | review: { | ||
| 91 | getDiffPreview: vi.fn(), | ||
| 92 | }, | ||
| 85 | server: { | 93 | server: { |
| 86 | getConfig: vi.fn(), | 94 | getConfig: vi.fn(), |
| 87 | refreshProviders: vi.fn(), | 95 | refreshProviders: vi.fn(), |
| ... | @@ -126,6 +134,7 @@ vi.mock("./environments/runtime", () => ({ | ... | @@ -126,6 +134,7 @@ vi.mock("./environments/runtime", () => ({ |
| 126 | resetEnvironmentServiceForTests: vi.fn(), | 134 | resetEnvironmentServiceForTests: vi.fn(), |
| 127 | resetSavedEnvironmentRegistryStoreForTests: vi.fn(), | 135 | resetSavedEnvironmentRegistryStoreForTests: vi.fn(), |
| 128 | resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), | 136 | resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), |
| 137 | subscribeEnvironmentConnections: vi.fn(() => () => undefined), | ||
| 129 | })); | 138 | })); |
| 130 | 139 | ||
| 131 | vi.mock("./contextMenuFallback", () => ({ | 140 | vi.mock("./contextMenuFallback", () => ({ |
| ... | @@ -315,7 +324,8 @@ beforeEach(() => { | ... | @@ -315,7 +324,8 @@ beforeEach(() => { |
| 315 | vi.resetModules(); | 324 | vi.resetModules(); |
| 316 | vi.clearAllMocks(); | 325 | vi.clearAllMocks(); |
| 317 | showContextMenuFallbackMock.mockReset(); | 326 | showContextMenuFallbackMock.mockReset(); |
| 318 | terminalEventListeners.clear(); | 327 | terminalAttachListeners.clear(); |
| 328 | terminalMetadataListeners.clear(); | ||
| 319 | shellStreamListeners.clear(); | 329 | shellStreamListeners.clear(); |
| 320 | gitStatusListeners.clear(); | 330 | gitStatusListeners.clear(); |
| 321 | const testWindow = getWindowForTest(); | 331 | const testWindow = getWindowForTest(); |
| ... | @@ -343,24 +353,43 @@ describe("wsApi", () => { | ... | @@ -343,24 +353,43 @@ describe("wsApi", () => { |
| 343 | expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); | 353 | expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); |
| 344 | }); | 354 | }); |
| 345 | 355 | ||
| 346 | it("forwards terminal and shell stream events", async () => { | 356 | it("forwards terminal attach, metadata, and shell stream events", async () => { |
| 347 | const { createEnvironmentApi } = await import("./environmentApi"); | 357 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 348 | 358 | ||
| 349 | const api = createEnvironmentApi(rpcClientMock as never); | 359 | const api = createEnvironmentApi(rpcClientMock as never); |
| 350 | const onTerminalEvent = vi.fn(); | 360 | const onTerminalAttachEvent = vi.fn(); |
| 361 | const onTerminalMetadataEvent = vi.fn(); | ||
| 351 | const onShellEvent = vi.fn(); | 362 | const onShellEvent = vi.fn(); |
| 352 | 363 | ||
| 353 | api.terminal.onEvent(onTerminalEvent); | 364 | api.terminal.attach({ threadId: "thread-1", terminalId: "terminal-1" }, onTerminalAttachEvent); |
| 365 | api.terminal.onMetadata(onTerminalMetadataEvent); | ||
| 354 | api.orchestration.subscribeShell(onShellEvent); | 366 | api.orchestration.subscribeShell(onShellEvent); |
| 355 | 367 | ||
| 356 | const terminalEvent = { | 368 | const terminalAttachEvent = { |
| 357 | threadId: "thread-1", | 369 | threadId: "thread-1", |
| 358 | terminalId: "terminal-1", | 370 | terminalId: "terminal-1", |
| 359 | createdAt: "2026-02-24T00:00:00.000Z", | ||
| 360 | type: "output", | 371 | type: "output", |
| 361 | data: "hello", | 372 | data: "hello", |
| 362 | } as const; | 373 | } satisfies TerminalAttachStreamEvent; |
| 363 | emitEvent(terminalEventListeners, terminalEvent); | 374 | emitEvent(terminalAttachListeners, terminalAttachEvent); |
| 375 | |||
| 376 | const terminalMetadataEvent = { | ||
| 377 | type: "upsert", | ||
| 378 | terminal: { | ||
| 379 | threadId: "thread-1", | ||
| 380 | terminalId: "terminal-1", | ||
| 381 | cwd: "/tmp/workspace", | ||
| 382 | worktreePath: null, | ||
| 383 | status: "running", | ||
| 384 | pid: 123, | ||
| 385 | exitCode: null, | ||
| 386 | exitSignal: null, | ||
| 387 | hasRunningSubprocess: true, | ||
| 388 | label: "terminal-1", | ||
| 389 | updatedAt: "2026-02-24T00:00:00.000Z", | ||
| 390 | }, | ||
| 391 | } satisfies TerminalMetadataStreamEvent; | ||
| 392 | emitEvent(terminalMetadataListeners, terminalMetadataEvent); | ||
| 364 | 393 | ||
| 365 | const shellEvent = { | 394 | const shellEvent = { |
| 366 | kind: "project-upserted" as const, | 395 | kind: "project-upserted" as const, |
| ... | @@ -380,7 +409,8 @@ describe("wsApi", () => { | ... | @@ -380,7 +409,8 @@ describe("wsApi", () => { |
| 380 | } satisfies OrchestrationShellStreamItem; | 409 | } satisfies OrchestrationShellStreamItem; |
| 381 | emitEvent(shellStreamListeners, shellEvent); | 410 | emitEvent(shellStreamListeners, shellEvent); |
| 382 | 411 | ||
| 383 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); | 412 | expect(onTerminalAttachEvent).toHaveBeenCalledWith(terminalAttachEvent); |
| 413 | expect(onTerminalMetadataEvent).toHaveBeenCalledWith(terminalMetadataEvent); | ||
| 384 | expect(onShellEvent).toHaveBeenCalledWith(shellEvent); | 414 | expect(onShellEvent).toHaveBeenCalledWith(shellEvent); |
| 385 | }); | 415 | }); |
| 386 | 416 |
apps/web/src/localApi.ts+3-3| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; | 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; |
| 2 | import type { WsRpcClient } from "@t3tools/client-runtime"; | ||
| 2 | 3 | ||
| 3 | import { resetGitStatusStateForTests } from "./lib/gitStatusState"; | 4 | import { resetVcsStatusStateForTests } from "./lib/vcsStatusState"; |
| 4 | import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState"; | 5 | import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState"; |
| 5 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; | 6 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; |
| 6 | import { resetServerStateForTests } from "./rpc/serverState"; | 7 | import { resetServerStateForTests } from "./rpc/serverState"; |
| ... | @@ -14,7 +15,6 @@ import { | ... | @@ -14,7 +15,6 @@ import { |
| 14 | resetEnvironmentServiceForTests, | 15 | resetEnvironmentServiceForTests, |
| 15 | } from "./environments/runtime"; | 16 | } from "./environments/runtime"; |
| 16 | import { getPrimaryKnownEnvironment } from "./environments/primary"; | 17 | import { getPrimaryKnownEnvironment } from "./environments/primary"; |
| 17 | import { type WsRpcClient } from "./rpc/wsRpcClient"; | ||
| 18 | import { showContextMenuFallback } from "./contextMenuFallback"; | 18 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 19 | import { | 19 | import { |
| 20 | readBrowserClientSettings, | 20 | readBrowserClientSettings, |
| ... | @@ -200,7 +200,7 @@ export async function __resetLocalApiForTests() { | ... | @@ -200,7 +200,7 @@ export async function __resetLocalApiForTests() { |
| 200 | const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); | 200 | const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); |
| 201 | __resetClientSettingsPersistenceForTests(); | 201 | __resetClientSettingsPersistenceForTests(); |
| 202 | await resetEnvironmentServiceForTests(); | 202 | await resetEnvironmentServiceForTests(); |
| 203 | resetGitStatusStateForTests(); | 203 | resetVcsStatusStateForTests(); |
| 204 | resetSourceControlDiscoveryStateForTests(); | 204 | resetSourceControlDiscoveryStateForTests(); |
| 205 | resetRequestLatencyStateForTests(); | 205 | resetRequestLatencyStateForTests(); |
| 206 | resetSavedEnvironmentRegistryStoreForTests(); | 206 | resetSavedEnvironmentRegistryStoreForTests(); |
apps/web/src/orchestrationEventEffects.test.ts+3-3| ... | @@ -71,7 +71,7 @@ describe("deriveOrchestrationBatchEffects", () => { | ... | @@ -71,7 +71,7 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 71 | 71 | ||
| 72 | expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]); | 72 | expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]); |
| 73 | expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); | 73 | expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); |
| 74 | expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]); | 74 | expect(effects.removeTerminalUiStateThreadIds).toEqual([deletedThreadId, archivedThreadId]); |
| 75 | expect(effects.needsProviderInvalidation).toBe(false); | 75 | expect(effects.needsProviderInvalidation).toBe(false); |
| 76 | }); | 76 | }); |
| 77 | 77 | ||
| ... | @@ -109,7 +109,7 @@ describe("deriveOrchestrationBatchEffects", () => { | ... | @@ -109,7 +109,7 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 109 | 109 | ||
| 110 | expect(effects.promoteDraftThreadIds).toEqual([threadId]); | 110 | expect(effects.promoteDraftThreadIds).toEqual([threadId]); |
| 111 | expect(effects.clearDeletedThreadIds).toEqual([]); | 111 | expect(effects.clearDeletedThreadIds).toEqual([]); |
| 112 | expect(effects.removeTerminalStateThreadIds).toEqual([]); | 112 | expect(effects.removeTerminalUiStateThreadIds).toEqual([]); |
| 113 | expect(effects.needsProviderInvalidation).toBe(true); | 113 | expect(effects.needsProviderInvalidation).toBe(true); |
| 114 | }); | 114 | }); |
| 115 | 115 | ||
| ... | @@ -130,6 +130,6 @@ describe("deriveOrchestrationBatchEffects", () => { | ... | @@ -130,6 +130,6 @@ describe("deriveOrchestrationBatchEffects", () => { |
| 130 | 130 | ||
| 131 | expect(effects.promoteDraftThreadIds).toEqual([]); | 131 | expect(effects.promoteDraftThreadIds).toEqual([]); |
| 132 | expect(effects.clearDeletedThreadIds).toEqual([]); | 132 | expect(effects.clearDeletedThreadIds).toEqual([]); |
| 133 | expect(effects.removeTerminalStateThreadIds).toEqual([]); | 133 | expect(effects.removeTerminalUiStateThreadIds).toEqual([]); |
| 134 | }); | 134 | }); |
| 135 | }); | 135 | }); |
apps/web/src/orchestrationEventEffects.ts+10-10| ... | @@ -3,7 +3,7 @@ import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; | ... | @@ -3,7 +3,7 @@ import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; |
| 3 | export interface OrchestrationBatchEffects { | 3 | export interface OrchestrationBatchEffects { |
| 4 | promoteDraftThreadIds: ThreadId[]; | 4 | promoteDraftThreadIds: ThreadId[]; |
| 5 | clearDeletedThreadIds: ThreadId[]; | 5 | clearDeletedThreadIds: ThreadId[]; |
| 6 | removeTerminalStateThreadIds: ThreadId[]; | 6 | removeTerminalUiStateThreadIds: ThreadId[]; |
| 7 | needsProviderInvalidation: boolean; | 7 | needsProviderInvalidation: boolean; |
| 8 | } | 8 | } |
| 9 | 9 | ||
| ... | @@ -15,7 +15,7 @@ export function deriveOrchestrationBatchEffects( | ... | @@ -15,7 +15,7 @@ export function deriveOrchestrationBatchEffects( |
| 15 | { | 15 | { |
| 16 | clearPromotedDraft: boolean; | 16 | clearPromotedDraft: boolean; |
| 17 | clearDeletedThread: boolean; | 17 | clearDeletedThread: boolean; |
| 18 | removeTerminalState: boolean; | 18 | removeTerminalUiState: boolean; |
| 19 | } | 19 | } |
| 20 | >(); | 20 | >(); |
| 21 | let needsProviderInvalidation = false; | 21 | let needsProviderInvalidation = false; |
| ... | @@ -32,7 +32,7 @@ export function deriveOrchestrationBatchEffects( | ... | @@ -32,7 +32,7 @@ export function deriveOrchestrationBatchEffects( |
| 32 | threadLifecycleEffects.set(event.payload.threadId, { | 32 | threadLifecycleEffects.set(event.payload.threadId, { |
| 33 | clearPromotedDraft: true, | 33 | clearPromotedDraft: true, |
| 34 | clearDeletedThread: false, | 34 | clearDeletedThread: false, |
| 35 | removeTerminalState: false, | 35 | removeTerminalUiState: false, |
| 36 | }); | 36 | }); |
| 37 | break; | 37 | break; |
| 38 | } | 38 | } |
| ... | @@ -41,7 +41,7 @@ export function deriveOrchestrationBatchEffects( | ... | @@ -41,7 +41,7 @@ export function deriveOrchestrationBatchEffects( |
| 41 | threadLifecycleEffects.set(event.payload.threadId, { | 41 | threadLifecycleEffects.set(event.payload.threadId, { |
| 42 | clearPromotedDraft: false, | 42 | clearPromotedDraft: false, |
| 43 | clearDeletedThread: true, | 43 | clearDeletedThread: true, |
| 44 | removeTerminalState: true, | 44 | removeTerminalUiState: true, |
| 45 | }); | 45 | }); |
| 46 | break; | 46 | break; |
| 47 | } | 47 | } |
| ... | @@ -50,7 +50,7 @@ export function deriveOrchestrationBatchEffects( | ... | @@ -50,7 +50,7 @@ export function deriveOrchestrationBatchEffects( |
| 50 | threadLifecycleEffects.set(event.payload.threadId, { | 50 | threadLifecycleEffects.set(event.payload.threadId, { |
| 51 | clearPromotedDraft: false, | 51 | clearPromotedDraft: false, |
| 52 | clearDeletedThread: false, | 52 | clearDeletedThread: false, |
| 53 | removeTerminalState: true, | 53 | removeTerminalUiState: true, |
| 54 | }); | 54 | }); |
| 55 | break; | 55 | break; |
| 56 | } | 56 | } |
| ... | @@ -59,7 +59,7 @@ export function deriveOrchestrationBatchEffects( | ... | @@ -59,7 +59,7 @@ export function deriveOrchestrationBatchEffects( |
| 59 | threadLifecycleEffects.set(event.payload.threadId, { | 59 | threadLifecycleEffects.set(event.payload.threadId, { |
| 60 | clearPromotedDraft: false, | 60 | clearPromotedDraft: false, |
| 61 | clearDeletedThread: false, | 61 | clearDeletedThread: false, |
| 62 | removeTerminalState: false, | 62 | removeTerminalUiState: false, |
| 63 | }); | 63 | }); |
| 64 | break; | 64 | break; |
| 65 | } | 65 | } |
| ... | @@ -72,7 +72,7 @@ export function deriveOrchestrationBatchEffects( | ... | @@ -72,7 +72,7 @@ export function deriveOrchestrationBatchEffects( |
| 72 | 72 | ||
| 73 | const promoteDraftThreadIds: ThreadId[] = []; | 73 | const promoteDraftThreadIds: ThreadId[] = []; |
| 74 | const clearDeletedThreadIds: ThreadId[] = []; | 74 | const clearDeletedThreadIds: ThreadId[] = []; |
| 75 | const removeTerminalStateThreadIds: ThreadId[] = []; | 75 | const removeTerminalUiStateThreadIds: ThreadId[] = []; |
| 76 | for (const [threadId, effect] of threadLifecycleEffects) { | 76 | for (const [threadId, effect] of threadLifecycleEffects) { |
| 77 | if (effect.clearPromotedDraft) { | 77 | if (effect.clearPromotedDraft) { |
| 78 | promoteDraftThreadIds.push(threadId); | 78 | promoteDraftThreadIds.push(threadId); |
| ... | @@ -80,15 +80,15 @@ export function deriveOrchestrationBatchEffects( | ... | @@ -80,15 +80,15 @@ export function deriveOrchestrationBatchEffects( |
| 80 | if (effect.clearDeletedThread) { | 80 | if (effect.clearDeletedThread) { |
| 81 | clearDeletedThreadIds.push(threadId); | 81 | clearDeletedThreadIds.push(threadId); |
| 82 | } | 82 | } |
| 83 | if (effect.removeTerminalState) { | 83 | if (effect.removeTerminalUiState) { |
| 84 | removeTerminalStateThreadIds.push(threadId); | 84 | removeTerminalUiStateThreadIds.push(threadId); |
| 85 | } | 85 | } |
| 86 | } | 86 | } |
| 87 | 87 | ||
| 88 | return { | 88 | return { |
| 89 | promoteDraftThreadIds, | 89 | promoteDraftThreadIds, |
| 90 | clearDeletedThreadIds, | 90 | clearDeletedThreadIds, |
| 91 | removeTerminalStateThreadIds, | 91 | removeTerminalUiStateThreadIds, |
| 92 | needsProviderInvalidation, | 92 | needsProviderInvalidation, |
| 93 | }; | 93 | }; |
| 94 | } | 94 | } |
apps/web/src/pairingUrl.ts+5-33| ... | @@ -1,33 +1,5 @@ | ... | @@ -1,33 +1,5 @@ |
| 1 | const PAIRING_TOKEN_PARAM = "token"; | 1 | export { |
| 2 | 2 | getPairingTokenFromUrl, | |
| 3 | function readHashParams(url: URL): URLSearchParams { | 3 | setPairingTokenOnUrl, |
| 4 | return new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); | 4 | stripPairingTokenFromUrl, |
| 5 | } | 5 | } from "@t3tools/shared/remote"; |
| 6 | |||
| 7 | export function getPairingTokenFromUrl(url: URL): string | null { | ||
| 8 | const hashToken = readHashParams(url).get(PAIRING_TOKEN_PARAM)?.trim() ?? ""; | ||
| 9 | if (hashToken.length > 0) { | ||
| 10 | return hashToken; | ||
| 11 | } | ||
| 12 | |||
| 13 | const searchToken = url.searchParams.get(PAIRING_TOKEN_PARAM)?.trim() ?? ""; | ||
| 14 | return searchToken.length > 0 ? searchToken : null; | ||
| 15 | } | ||
| 16 | |||
| 17 | export function stripPairingTokenFromUrl(url: URL): URL { | ||
| 18 | const next = new URL(url.toString()); | ||
| 19 | const hashParams = readHashParams(next); | ||
| 20 | if (hashParams.has(PAIRING_TOKEN_PARAM)) { | ||
| 21 | hashParams.delete(PAIRING_TOKEN_PARAM); | ||
| 22 | next.hash = hashParams.toString(); | ||
| 23 | } | ||
| 24 | next.searchParams.delete(PAIRING_TOKEN_PARAM); | ||
| 25 | return next; | ||
| 26 | } | ||
| 27 | |||
| 28 | export function setPairingTokenOnUrl(url: URL, credential: string): URL { | ||
| 29 | const next = new URL(url.toString()); | ||
| 30 | next.searchParams.delete(PAIRING_TOKEN_PARAM); | ||
| 31 | next.hash = new URLSearchParams([[PAIRING_TOKEN_PARAM, credential]]).toString(); | ||
| 32 | return next; | ||
| 33 | } |
apps/web/src/reviewCommentContext.test.ts created+77| ... | @@ -0,0 +1,77 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | buildReviewCommentRenderablePatch, | ||
| 5 | parseReviewCommentMessageSegments, | ||
| 6 | } from "./reviewCommentContext"; | ||
| 7 | |||
| 8 | describe("review comment context parsing", () => { | ||
| 9 | it("extracts comment metadata, user text, and fenced diff without raw wrapper text", () => { | ||
| 10 | const segments = parseReviewCommentMessageSegments( | ||
| 11 | [ | ||
| 12 | 'Before <review_comment sectionId="turn:2" sectionTitle="Turn 2" filePath="apps/web/src/lib/contextWindow.test.ts" startIndex="3" endIndex="14" rangeLabel="+47 to +58">', | ||
| 13 | "Wadduo", | ||
| 14 | "```diff", | ||
| 15 | "@@ -0,0 +47,2 @@", | ||
| 16 | '+ it("keeps valid zero-usage snapshots", () => {', | ||
| 17 | "+ expect(snapshot).not.toBeNull();", | ||
| 18 | "```", | ||
| 19 | "</review_comment> after", | ||
| 20 | ].join("\n"), | ||
| 21 | ); | ||
| 22 | |||
| 23 | expect(segments).toHaveLength(3); | ||
| 24 | expect(segments[0]).toEqual( | ||
| 25 | expect.objectContaining({ | ||
| 26 | kind: "text", | ||
| 27 | text: expect.stringContaining("Before"), | ||
| 28 | }), | ||
| 29 | ); | ||
| 30 | expect(segments[1]).toEqual( | ||
| 31 | expect.objectContaining({ | ||
| 32 | kind: "review-comment", | ||
| 33 | comment: expect.objectContaining({ | ||
| 34 | filePath: "apps/web/src/lib/contextWindow.test.ts", | ||
| 35 | rangeLabel: "+47 to +58", | ||
| 36 | text: "Wadduo", | ||
| 37 | diff: expect.stringContaining('it("keeps valid zero-usage snapshots"'), | ||
| 38 | }), | ||
| 39 | }), | ||
| 40 | ); | ||
| 41 | expect(segments[2]).toEqual( | ||
| 42 | expect.objectContaining({ | ||
| 43 | kind: "text", | ||
| 44 | text: " after", | ||
| 45 | }), | ||
| 46 | ); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("wraps hunk-only review diffs in a renderable file patch", () => { | ||
| 50 | const [segment] = parseReviewCommentMessageSegments( | ||
| 51 | [ | ||
| 52 | '<review_comment sectionId="s" filePath="src/app.ts" startIndex="0" endIndex="0">', | ||
| 53 | "Please check this.", | ||
| 54 | "```diff", | ||
| 55 | "@@ -1,1 +1,1 @@", | ||
| 56 | "-old", | ||
| 57 | "+new", | ||
| 58 | "```", | ||
| 59 | "</review_comment>", | ||
| 60 | ].join("\n"), | ||
| 61 | ); | ||
| 62 | |||
| 63 | expect(segment?.kind).toBe("review-comment"); | ||
| 64 | if (segment?.kind !== "review-comment") return; | ||
| 65 | |||
| 66 | expect(buildReviewCommentRenderablePatch(segment.comment)).toBe( | ||
| 67 | [ | ||
| 68 | "diff --git a/src/app.ts b/src/app.ts", | ||
| 69 | "--- a/src/app.ts", | ||
| 70 | "+++ b/src/app.ts", | ||
| 71 | "@@ -1,1 +1,1 @@", | ||
| 72 | "-old", | ||
| 73 | "+new", | ||
| 74 | ].join("\n"), | ||
| 75 | ); | ||
| 76 | }); | ||
| 77 | }); | ||
apps/web/src/reviewCommentContext.ts created+154| ... | @@ -0,0 +1,154 @@ | ||
| 1 | export interface ReviewCommentContext { | ||
| 2 | readonly id: string; | ||
| 3 | readonly sectionId: string; | ||
| 4 | readonly sectionTitle: string; | ||
| 5 | readonly filePath: string; | ||
| 6 | readonly startIndex: number; | ||
| 7 | readonly endIndex: number; | ||
| 8 | readonly rangeLabel: string; | ||
| 9 | readonly text: string; | ||
| 10 | readonly diff: string; | ||
| 11 | } | ||
| 12 | |||
| 13 | export type ReviewCommentMessageSegment = | ||
| 14 | | { | ||
| 15 | readonly kind: "text"; | ||
| 16 | readonly id: string; | ||
| 17 | readonly text: string; | ||
| 18 | } | ||
| 19 | | { | ||
| 20 | readonly kind: "review-comment"; | ||
| 21 | readonly comment: ReviewCommentContext; | ||
| 22 | }; | ||
| 23 | |||
| 24 | const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g; | ||
| 25 | const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g; | ||
| 26 | const REVIEW_COMMENT_DIFF_FENCE_PATTERN = /```diff\s*\n([\s\S]*?)\n```/; | ||
| 27 | |||
| 28 | function unescapeReviewCommentAttribute(value: string): string { | ||
| 29 | return value | ||
| 30 | .replace(/&lt;/g, "<") | ||
| 31 | .replace(/&quot;/g, '"') | ||
| 32 | .replace(/&amp;/g, "&"); | ||
| 33 | } | ||
| 34 | |||
| 35 | function readReviewCommentAttributes(rawAttributes: string): Record<string, string> { | ||
| 36 | const attributes: Record<string, string> = {}; | ||
| 37 | for (const match of rawAttributes.matchAll(REVIEW_COMMENT_ATTRIBUTE_PATTERN)) { | ||
| 38 | attributes[match[1]!] = unescapeReviewCommentAttribute(match[2] ?? ""); | ||
| 39 | } | ||
| 40 | return attributes; | ||
| 41 | } | ||
| 42 | |||
| 43 | function readNonNegativeInteger(value: string | undefined): number | null { | ||
| 44 | if (value === undefined || !/^\d+$/.test(value)) { | ||
| 45 | return null; | ||
| 46 | } | ||
| 47 | return Number(value); | ||
| 48 | } | ||
| 49 | |||
| 50 | function extractReviewCommentText(rawBody: string): string { | ||
| 51 | const fenceIndex = rawBody.indexOf("```diff"); | ||
| 52 | const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody; | ||
| 53 | return commentBody.trim(); | ||
| 54 | } | ||
| 55 | |||
| 56 | function extractReviewCommentDiff(rawBody: string): string { | ||
| 57 | return rawBody.match(REVIEW_COMMENT_DIFF_FENCE_PATTERN)?.[1]?.trim() ?? ""; | ||
| 58 | } | ||
| 59 | |||
| 60 | function parseReviewCommentContext( | ||
| 61 | rawAttributes: string, | ||
| 62 | rawBody: string, | ||
| 63 | index: number, | ||
| 64 | ): ReviewCommentContext | null { | ||
| 65 | const attributes = readReviewCommentAttributes(rawAttributes); | ||
| 66 | const startIndex = readNonNegativeInteger(attributes.startIndex); | ||
| 67 | const endIndex = readNonNegativeInteger(attributes.endIndex); | ||
| 68 | const filePath = attributes.filePath?.trim(); | ||
| 69 | const sectionId = attributes.sectionId?.trim(); | ||
| 70 | if (!filePath || !sectionId || startIndex === null || endIndex === null) { | ||
| 71 | return null; | ||
| 72 | } | ||
| 73 | |||
| 74 | return { | ||
| 75 | id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`, | ||
| 76 | sectionId, | ||
| 77 | sectionTitle: attributes.sectionTitle?.trim() || "Review", | ||
| 78 | filePath, | ||
| 79 | startIndex: Math.min(startIndex, endIndex), | ||
| 80 | endIndex: Math.max(startIndex, endIndex), | ||
| 81 | rangeLabel: attributes.rangeLabel?.trim() || "line", | ||
| 82 | text: extractReviewCommentText(rawBody), | ||
| 83 | diff: extractReviewCommentDiff(rawBody), | ||
| 84 | }; | ||
| 85 | } | ||
| 86 | |||
| 87 | export function parseReviewCommentMessageSegments( | ||
| 88 | value: string, | ||
| 89 | ): ReadonlyArray<ReviewCommentMessageSegment> { | ||
| 90 | const segments: ReviewCommentMessageSegment[] = []; | ||
| 91 | let cursor = 0; | ||
| 92 | let parsedCommentIndex = 0; | ||
| 93 | |||
| 94 | for (const match of value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) { | ||
| 95 | const matchIndex = match.index ?? 0; | ||
| 96 | const beforeText = value.slice(cursor, matchIndex); | ||
| 97 | if (beforeText.length > 0) { | ||
| 98 | segments.push({ | ||
| 99 | kind: "text", | ||
| 100 | id: `review-comment-text:${cursor}`, | ||
| 101 | text: beforeText, | ||
| 102 | }); | ||
| 103 | } | ||
| 104 | |||
| 105 | const comment = parseReviewCommentContext(match[1] ?? "", match[2] ?? "", parsedCommentIndex); | ||
| 106 | if (comment) { | ||
| 107 | segments.push({ kind: "review-comment", comment }); | ||
| 108 | parsedCommentIndex += 1; | ||
| 109 | } else { | ||
| 110 | segments.push({ | ||
| 111 | kind: "text", | ||
| 112 | id: `review-comment-invalid:${matchIndex}`, | ||
| 113 | text: match[0], | ||
| 114 | }); | ||
| 115 | } | ||
| 116 | |||
| 117 | cursor = matchIndex + match[0].length; | ||
| 118 | } | ||
| 119 | |||
| 120 | const rest = value.slice(cursor); | ||
| 121 | if (rest.length > 0) { | ||
| 122 | segments.push({ | ||
| 123 | kind: "text", | ||
| 124 | id: `review-comment-text:${cursor}`, | ||
| 125 | text: rest, | ||
| 126 | }); | ||
| 127 | } | ||
| 128 | |||
| 129 | return segments; | ||
| 130 | } | ||
| 131 | |||
| 132 | export function hasReviewCommentMessageSegments(value: string): boolean { | ||
| 133 | return parseReviewCommentMessageSegments(value).some( | ||
| 134 | (segment) => segment.kind === "review-comment", | ||
| 135 | ); | ||
| 136 | } | ||
| 137 | |||
| 138 | export function buildReviewCommentRenderablePatch(comment: ReviewCommentContext): string { | ||
| 139 | const diff = comment.diff.trim(); | ||
| 140 | if (diff.length === 0) { | ||
| 141 | return ""; | ||
| 142 | } | ||
| 143 | if (diff.startsWith("diff --git ")) { | ||
| 144 | return diff; | ||
| 145 | } | ||
| 146 | |||
| 147 | const normalizedPath = comment.filePath.replaceAll("\\", "/"); | ||
| 148 | return [ | ||
| 149 | `diff --git a/${normalizedPath} b/${normalizedPath}`, | ||
| 150 | `--- a/${normalizedPath}`, | ||
| 151 | `+++ b/${normalizedPath}`, | ||
| 152 | diff, | ||
| 153 | ].join("\n"); | ||
| 154 | } | ||
apps/web/src/routes/_chat.tsx+3-3| ... | @@ -9,7 +9,7 @@ import { | ... | @@ -9,7 +9,7 @@ import { |
| 9 | } from "../lib/chatThreadActions"; | 9 | } from "../lib/chatThreadActions"; |
| 10 | import { isTerminalFocused } from "../lib/terminalFocus"; | 10 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 11 | import { resolveShortcutCommand } from "../keybindings"; | 11 | import { resolveShortcutCommand } from "../keybindings"; |
| 12 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 12 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 13 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 13 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 14 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; | 14 | import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; |
| 15 | import { useSettings } from "~/hooks/useSettings"; | 15 | import { useSettings } from "~/hooks/useSettings"; |
| ... | @@ -21,9 +21,9 @@ function ChatRouteGlobalShortcuts() { | ... | @@ -21,9 +21,9 @@ function ChatRouteGlobalShortcuts() { |
| 21 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = | 21 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = |
| 22 | useHandleNewThread(); | 22 | useHandleNewThread(); |
| 23 | const keybindings = useServerKeybindings(); | 23 | const keybindings = useServerKeybindings(); |
| 24 | const terminalOpen = useTerminalStateStore((state) => | 24 | const terminalOpen = useTerminalUiStateStore((state) => |
| 25 | routeThreadRef | 25 | routeThreadRef |
| 26 | ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen | 26 | ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen |
| 27 | : false, | 27 | : false, |
| 28 | ); | 28 | ); |
| 29 | const appSettings = useSettings(); | 29 | const appSettings = useSettings(); |
apps/web/src/rpc/protocol.ts deleted-328| ... | @@ -1,328 +0,0 @@ | ||
| 1 | import { WsRpcGroup } from "@t3tools/contracts"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Schedule from "effect/Schedule"; | ||
| 6 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | ||
| 7 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | acknowledgeRpcRequest, | ||
| 11 | clearAllTrackedRpcRequests, | ||
| 12 | trackRpcRequestSent, | ||
| 13 | } from "./requestLatencyState"; | ||
| 14 | import { | ||
| 15 | getWsReconnectDelayMsForRetry, | ||
| 16 | recordWsConnectionAttempt, | ||
| 17 | recordWsConnectionClosed, | ||
| 18 | recordWsConnectionErrored, | ||
| 19 | recordWsConnectionOpened, | ||
| 20 | type WsConnectionMetadata, | ||
| 21 | WS_RECONNECT_MAX_RETRIES, | ||
| 22 | } from "./wsConnectionState"; | ||
| 23 | |||
| 24 | export interface WsProtocolCloseContext { | ||
| 25 | readonly intentional: boolean; | ||
| 26 | } | ||
| 27 | |||
| 28 | export interface WsProtocolLifecycleHandlers { | ||
| 29 | readonly getConnectionLabel?: () => string | null; | ||
| 30 | readonly getVersionMismatchHint?: () => string | null; | ||
| 31 | readonly isCloseIntentional?: () => boolean; | ||
| 32 | readonly isActive?: () => boolean; | ||
| 33 | readonly onAttempt?: (socketUrl: string) => void; | ||
| 34 | readonly onOpen?: () => void; | ||
| 35 | readonly onHeartbeatPing?: () => void; | ||
| 36 | readonly onHeartbeatPong?: () => void; | ||
| 37 | readonly onHeartbeatTimeout?: () => void; | ||
| 38 | readonly onRequestStart?: (info: { | ||
| 39 | readonly id: string; | ||
| 40 | readonly tag: string; | ||
| 41 | readonly stream: boolean; | ||
| 42 | }) => void; | ||
| 43 | readonly onRequestChunk?: (info: { | ||
| 44 | readonly id: string; | ||
| 45 | readonly tag: string; | ||
| 46 | readonly chunkCount: number; | ||
| 47 | }) => void; | ||
| 48 | readonly onRequestExit?: (info: { | ||
| 49 | readonly id: string; | ||
| 50 | readonly tag: string; | ||
| 51 | readonly stream: boolean; | ||
| 52 | }) => void; | ||
| 53 | readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void; | ||
| 54 | readonly onError?: (message: string) => void; | ||
| 55 | readonly onClose?: ( | ||
| 56 | details: { readonly code: number; readonly reason: string }, | ||
| 57 | context: WsProtocolCloseContext, | ||
| 58 | ) => void; | ||
| 59 | } | ||
| 60 | |||
| 61 | export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); | ||
| 62 | type RpcClientFactory = typeof makeWsRpcProtocolClient; | ||
| 63 | export type WsRpcProtocolClient = | ||
| 64 | RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never; | ||
| 65 | export type WsRpcProtocolSocketUrlProvider = string | (() => Promise<string>); | ||
| 66 | |||
| 67 | function formatSocketErrorMessage(error: unknown): string { | ||
| 68 | if (error instanceof Error && error.message.trim().length > 0) { | ||
| 69 | return error.message; | ||
| 70 | } | ||
| 71 | return String(error); | ||
| 72 | } | ||
| 73 | |||
| 74 | function resolveWsRpcSocketUrl(rawUrl: string): string { | ||
| 75 | const resolved = new URL(rawUrl); | ||
| 76 | if (resolved.protocol !== "ws:" && resolved.protocol !== "wss:") { | ||
| 77 | throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); | ||
| 78 | } | ||
| 79 | |||
| 80 | resolved.pathname = "/ws"; | ||
| 81 | return resolved.toString(); | ||
| 82 | } | ||
| 83 | |||
| 84 | function resolveConnectionMetadata(handlers?: WsProtocolLifecycleHandlers): WsConnectionMetadata { | ||
| 85 | return { | ||
| 86 | connectionLabel: handlers?.getConnectionLabel?.() ?? null, | ||
| 87 | versionMismatchHint: handlers?.getVersionMismatchHint?.() ?? null, | ||
| 88 | }; | ||
| 89 | } | ||
| 90 | |||
| 91 | type ComposedWsProtocolLifecycleHandlers = Required< | ||
| 92 | Pick<WsProtocolLifecycleHandlers, "isActive" | "onAttempt" | "onOpen" | "onError" | "onClose"> | ||
| 93 | >; | ||
| 94 | |||
| 95 | function defaultLifecycleHandlers( | ||
| 96 | handlers?: WsProtocolLifecycleHandlers, | ||
| 97 | ): ComposedWsProtocolLifecycleHandlers { | ||
| 98 | return { | ||
| 99 | isActive: () => true, | ||
| 100 | onAttempt: (socketUrl) => { | ||
| 101 | recordWsConnectionAttempt(socketUrl, resolveConnectionMetadata(handlers)); | ||
| 102 | }, | ||
| 103 | onOpen: () => { | ||
| 104 | recordWsConnectionOpened(resolveConnectionMetadata(handlers)); | ||
| 105 | }, | ||
| 106 | onError: (message) => { | ||
| 107 | clearAllTrackedRpcRequests(); | ||
| 108 | recordWsConnectionErrored(message, resolveConnectionMetadata(handlers)); | ||
| 109 | }, | ||
| 110 | onClose: (details, context) => { | ||
| 111 | clearAllTrackedRpcRequests(); | ||
| 112 | if (context.intentional) { | ||
| 113 | return; | ||
| 114 | } | ||
| 115 | recordWsConnectionClosed(details, resolveConnectionMetadata(handlers)); | ||
| 116 | }, | ||
| 117 | }; | ||
| 118 | } | ||
| 119 | |||
| 120 | function composeLifecycleHandlers( | ||
| 121 | handlers?: WsProtocolLifecycleHandlers, | ||
| 122 | ): ComposedWsProtocolLifecycleHandlers { | ||
| 123 | const defaults = defaultLifecycleHandlers(handlers); | ||
| 124 | const isActive = handlers?.isActive ?? defaults.isActive; | ||
| 125 | |||
| 126 | return { | ||
| 127 | isActive, | ||
| 128 | onAttempt: (socketUrl) => { | ||
| 129 | if (!isActive()) { | ||
| 130 | return; | ||
| 131 | } | ||
| 132 | defaults.onAttempt(socketUrl); | ||
| 133 | handlers?.onAttempt?.(socketUrl); | ||
| 134 | }, | ||
| 135 | onOpen: () => { | ||
| 136 | if (!isActive()) { | ||
| 137 | return; | ||
| 138 | } | ||
| 139 | defaults.onOpen(); | ||
| 140 | handlers?.onOpen?.(); | ||
| 141 | }, | ||
| 142 | onError: (message) => { | ||
| 143 | if (!isActive()) { | ||
| 144 | return; | ||
| 145 | } | ||
| 146 | defaults.onError(message); | ||
| 147 | handlers?.onError?.(message); | ||
| 148 | }, | ||
| 149 | onClose: (details, context) => { | ||
| 150 | if (!isActive()) { | ||
| 151 | return; | ||
| 152 | } | ||
| 153 | defaults.onClose(details, context); | ||
| 154 | handlers?.onClose?.(details, context); | ||
| 155 | }, | ||
| 156 | }; | ||
| 157 | } | ||
| 158 | |||
| 159 | export function createWsRpcProtocolLayer( | ||
| 160 | url: WsRpcProtocolSocketUrlProvider, | ||
| 161 | handlers?: WsProtocolLifecycleHandlers, | ||
| 162 | ) { | ||
| 163 | const lifecycle = composeLifecycleHandlers(handlers); | ||
| 164 | const resolvedUrl = | ||
| 165 | typeof url === "function" | ||
| 166 | ? Effect.promise(() => url()).pipe( | ||
| 167 | Effect.map((rawUrl) => resolveWsRpcSocketUrl(rawUrl)), | ||
| 168 | Effect.tapError((error) => | ||
| 169 | Effect.sync(() => { | ||
| 170 | lifecycle.onError(formatSocketErrorMessage(error)); | ||
| 171 | }), | ||
| 172 | ), | ||
| 173 | Effect.orDie, | ||
| 174 | ) | ||
| 175 | : resolveWsRpcSocketUrl(url); | ||
| 176 | |||
| 177 | const trackingWebSocketConstructorLayer = Layer.succeed( | ||
| 178 | Socket.WebSocketConstructor, | ||
| 179 | (socketUrl, protocols) => { | ||
| 180 | lifecycle.onAttempt(socketUrl); | ||
| 181 | const socket = new globalThis.WebSocket(socketUrl, protocols); | ||
| 182 | |||
| 183 | socket.addEventListener( | ||
| 184 | "open", | ||
| 185 | () => { | ||
| 186 | lifecycle.onOpen(); | ||
| 187 | }, | ||
| 188 | { once: true }, | ||
| 189 | ); | ||
| 190 | socket.addEventListener( | ||
| 191 | "error", | ||
| 192 | () => { | ||
| 193 | lifecycle.onError("Unable to connect to the T3 server WebSocket."); | ||
| 194 | }, | ||
| 195 | { once: true }, | ||
| 196 | ); | ||
| 197 | socket.addEventListener( | ||
| 198 | "close", | ||
| 199 | (event) => { | ||
| 200 | lifecycle.onClose( | ||
| 201 | { | ||
| 202 | code: event.code, | ||
| 203 | reason: event.reason, | ||
| 204 | }, | ||
| 205 | { | ||
| 206 | intentional: handlers?.isCloseIntentional?.() ?? false, | ||
| 207 | }, | ||
| 208 | ); | ||
| 209 | }, | ||
| 210 | { once: true }, | ||
| 211 | ); | ||
| 212 | |||
| 213 | return socket; | ||
| 214 | }, | ||
| 215 | ); | ||
| 216 | const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe( | ||
| 217 | Layer.provide(trackingWebSocketConstructorLayer), | ||
| 218 | ); | ||
| 219 | const retryPolicy = Schedule.addDelay(Schedule.recurs(WS_RECONNECT_MAX_RETRIES), (retryCount) => | ||
| 220 | Effect.succeed(Duration.millis(getWsReconnectDelayMsForRetry(retryCount) ?? 0)), | ||
| 221 | ); | ||
| 222 | const protocolLayer = Layer.effect( | ||
| 223 | RpcClient.Protocol, | ||
| 224 | Effect.map( | ||
| 225 | RpcClient.makeProtocolSocket({ | ||
| 226 | retryPolicy, | ||
| 227 | retryTransientErrors: true, | ||
| 228 | }), | ||
| 229 | (protocol) => ({ | ||
| 230 | ...protocol, | ||
| 231 | run: (clientId, writeResponse) => | ||
| 232 | protocol.run(clientId, (response) => { | ||
| 233 | if (response._tag === "ClientProtocolError" || response._tag === "Defect") { | ||
| 234 | clearAllTrackedRpcRequests(); | ||
| 235 | } | ||
| 236 | return writeResponse(response); | ||
| 237 | }), | ||
| 238 | }), | ||
| 239 | ), | ||
| 240 | ); | ||
| 241 | const requestHooksLayer = Layer.succeed( | ||
| 242 | RpcClient.RequestHooks, | ||
| 243 | RpcClient.RequestHooks.of({ | ||
| 244 | onRequestStart: (info) => | ||
| 245 | Effect.sync(() => { | ||
| 246 | if (!lifecycle.isActive()) { | ||
| 247 | return; | ||
| 248 | } | ||
| 249 | handlers?.onRequestStart?.({ | ||
| 250 | id: String(info.id), | ||
| 251 | tag: info.tag, | ||
| 252 | stream: info.stream, | ||
| 253 | }); | ||
| 254 | trackRpcRequestSent(String(info.id), info.tag); | ||
| 255 | }), | ||
| 256 | onRequestChunk: (info) => | ||
| 257 | Effect.sync(() => { | ||
| 258 | if (!lifecycle.isActive()) { | ||
| 259 | return; | ||
| 260 | } | ||
| 261 | handlers?.onRequestChunk?.({ | ||
| 262 | id: String(info.id), | ||
| 263 | tag: info.tag, | ||
| 264 | chunkCount: info.chunkCount, | ||
| 265 | }); | ||
| 266 | acknowledgeRpcRequest(String(info.id)); | ||
| 267 | }), | ||
| 268 | onRequestExit: (info) => | ||
| 269 | Effect.sync(() => { | ||
| 270 | if (!lifecycle.isActive()) { | ||
| 271 | return; | ||
| 272 | } | ||
| 273 | handlers?.onRequestExit?.({ | ||
| 274 | id: String(info.id), | ||
| 275 | tag: info.tag, | ||
| 276 | stream: info.stream, | ||
| 277 | }); | ||
| 278 | acknowledgeRpcRequest(String(info.id)); | ||
| 279 | }), | ||
| 280 | onRequestInterrupt: (info) => | ||
| 281 | Effect.sync(() => { | ||
| 282 | if (!lifecycle.isActive()) { | ||
| 283 | return; | ||
| 284 | } | ||
| 285 | handlers?.onRequestInterrupt?.({ | ||
| 286 | id: String(info.id), | ||
| 287 | ...(info.tag === undefined ? {} : { tag: info.tag }), | ||
| 288 | }); | ||
| 289 | acknowledgeRpcRequest(String(info.id)); | ||
| 290 | }), | ||
| 291 | }), | ||
| 292 | ); | ||
| 293 | const connectionHooksLayer = Layer.succeed( | ||
| 294 | RpcClient.ConnectionHooks, | ||
| 295 | RpcClient.ConnectionHooks.of({ | ||
| 296 | onConnect: Effect.void, | ||
| 297 | onDisconnect: Effect.void, | ||
| 298 | onPing: Effect.sync(() => { | ||
| 299 | if (lifecycle.isActive()) { | ||
| 300 | handlers?.onHeartbeatPing?.(); | ||
| 301 | } | ||
| 302 | }), | ||
| 303 | onPong: Effect.sync(() => { | ||
| 304 | if (lifecycle.isActive()) { | ||
| 305 | handlers?.onHeartbeatPong?.(); | ||
| 306 | } | ||
| 307 | }), | ||
| 308 | onPingTimeout: Effect.sync(() => { | ||
| 309 | if (lifecycle.isActive()) { | ||
| 310 | clearAllTrackedRpcRequests(); | ||
| 311 | recordWsConnectionErrored( | ||
| 312 | "WebSocket heartbeat timed out.", | ||
| 313 | resolveConnectionMetadata(handlers), | ||
| 314 | ); | ||
| 315 | handlers?.onHeartbeatTimeout?.(); | ||
| 316 | } | ||
| 317 | }), | ||
| 318 | }), | ||
| 319 | ); | ||
| 320 | |||
| 321 | return Layer.mergeAll( | ||
| 322 | protocolLayer.pipe( | ||
| 323 | Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)), | ||
| 324 | ), | ||
| 325 | requestHooksLayer, | ||
| 326 | connectionHooksLayer, | ||
| 327 | ); | ||
| 328 | } | ||
apps/web/src/rpc/serverState.ts+1-1| ... | @@ -14,7 +14,7 @@ import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | ... | @@ -14,7 +14,7 @@ import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; |
| 14 | import { Atom } from "effect/unstable/reactivity"; | 14 | import { Atom } from "effect/unstable/reactivity"; |
| 15 | import { useCallback, useRef } from "react"; | 15 | import { useCallback, useRef } from "react"; |
| 16 | 16 | ||
| 17 | import type { WsRpcClient } from "./wsRpcClient"; | 17 | import type { WsRpcClient } from "@t3tools/client-runtime"; |
| 18 | import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; | 18 | import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; |
| 19 | 19 | ||
| 20 | export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; | 20 | export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; |
apps/web/src/rpc/transportError.test.ts deleted-24| ... | @@ -1,24 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transportError"; | ||
| 4 | |||
| 5 | describe("transportError", () => { | ||
| 6 | it("detects websocket transport failures", () => { | ||
| 7 | expect(isTransportConnectionErrorMessage("SocketCloseError: 1006")).toBe(true); | ||
| 8 | expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( | ||
| 9 | true, | ||
| 10 | ); | ||
| 11 | expect(isTransportConnectionErrorMessage("SocketOpenError: Timeout")).toBe(true); | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("preserves non-transport thread errors", () => { | ||
| 15 | expect(sanitizeThreadErrorMessage("Turn failed")).toBe("Turn failed"); | ||
| 16 | expect(sanitizeThreadErrorMessage("Select a base branch before sending.")).toBe( | ||
| 17 | "Select a base branch before sending.", | ||
| 18 | ); | ||
| 19 | }); | ||
| 20 | |||
| 21 | it("drops transport failures from thread surfaces", () => { | ||
| 22 | expect(sanitizeThreadErrorMessage("SocketCloseError: 1006")).toBeNull(); | ||
| 23 | }); | ||
| 24 | }); | ||
apps/web/src/rpc/transportError.ts+4-23| ... | @@ -1,23 +1,4 @@ | ... | @@ -1,23 +1,4 @@ |
| 1 | const TRANSPORT_ERROR_PATTERNS = [ | 1 | export { |
| 2 | /\bSocketCloseError\b/i, | 2 | isTransportConnectionErrorMessage, |
| 3 | /\bSocketOpenError\b/i, | 3 | sanitizeThreadErrorMessage, |
| 4 | /Unable to connect to the T3 server WebSocket\./i, | 4 | } from "@t3tools/client-runtime"; |
| 5 | /\bping timeout\b/i, | ||
| 6 | ] as const; | ||
| 7 | |||
| 8 | export function isTransportConnectionErrorMessage(message: string | null | undefined): boolean { | ||
| 9 | if (typeof message !== "string") { | ||
| 10 | return false; | ||
| 11 | } | ||
| 12 | |||
| 13 | const normalizedMessage = message.trim(); | ||
| 14 | if (normalizedMessage.length === 0) { | ||
| 15 | return false; | ||
| 16 | } | ||
| 17 | |||
| 18 | return TRANSPORT_ERROR_PATTERNS.some((pattern) => pattern.test(normalizedMessage)); | ||
| 19 | } | ||
| 20 | |||
| 21 | export function sanitizeThreadErrorMessage(message: string | null | undefined): string | null { | ||
| 22 | return isTransportConnectionErrorMessage(message) ? null : (message ?? null); | ||
| 23 | } |
apps/web/src/rpc/wsConnectionState.ts+6-12| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { DEFAULT_RECONNECT_BACKOFF, getReconnectDelayMs } from "@t3tools/client-runtime"; | ||
| 2 | import { Atom } from "effect/unstable/reactivity"; | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 4 | ||
| 4 | import { appAtomRegistry } from "./atomRegistry"; | 5 | import { appAtomRegistry } from "./atomRegistry"; |
| ... | @@ -6,10 +7,10 @@ import { appAtomRegistry } from "./atomRegistry"; | ... | @@ -6,10 +7,10 @@ import { appAtomRegistry } from "./atomRegistry"; |
| 6 | export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting"; | 7 | export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting"; |
| 7 | export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting"; | 8 | export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting"; |
| 8 | 9 | ||
| 9 | export const WS_RECONNECT_INITIAL_DELAY_MS = 1_000; | 10 | export const WS_RECONNECT_INITIAL_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.initialDelayMs; |
| 10 | export const WS_RECONNECT_BACKOFF_FACTOR = 2; | 11 | export const WS_RECONNECT_BACKOFF_FACTOR = DEFAULT_RECONNECT_BACKOFF.backoffFactor; |
| 11 | export const WS_RECONNECT_MAX_DELAY_MS = 64_000; | 12 | export const WS_RECONNECT_MAX_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.maxDelayMs; |
| 12 | export const WS_RECONNECT_MAX_RETRIES = 7; | 13 | export const WS_RECONNECT_MAX_RETRIES = DEFAULT_RECONNECT_BACKOFF.maxRetries!; |
| 13 | export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1; | 14 | export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1; |
| 14 | 15 | ||
| 15 | export interface WsConnectionStatus { | 16 | export interface WsConnectionStatus { |
| ... | @@ -201,14 +202,7 @@ export function useWsConnectionStatus(): WsConnectionStatus { | ... | @@ -201,14 +202,7 @@ export function useWsConnectionStatus(): WsConnectionStatus { |
| 201 | } | 202 | } |
| 202 | 203 | ||
| 203 | export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null { | 204 | export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null { |
| 204 | if (!Number.isInteger(retryIndex) || retryIndex < 0 || retryIndex >= WS_RECONNECT_MAX_RETRIES) { | 205 | return getReconnectDelayMs(retryIndex); |
| 205 | return null; | ||
| 206 | } | ||
| 207 | |||
| 208 | return Math.min( | ||
| 209 | Math.round(WS_RECONNECT_INITIAL_DELAY_MS * WS_RECONNECT_BACKOFF_FACTOR ** retryIndex), | ||
| 210 | WS_RECONNECT_MAX_DELAY_MS, | ||
| 211 | ); | ||
| 212 | } | 206 | } |
| 213 | 207 | ||
| 214 | function applyDisconnectState( | 208 | function applyDisconnectState( |
apps/web/src/rpc/wsRpcClient.test.ts deleted-105| ... | @@ -1,105 +0,0 @@ | ||
| 1 | import type { | ||
| 2 | VcsStatusLocalResult, | ||
| 3 | VcsStatusRemoteResult, | ||
| 4 | VcsStatusStreamEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { describe, expect, it, vi } from "vitest"; | ||
| 7 | |||
| 8 | vi.mock("./wsTransport", () => ({ | ||
| 9 | WsTransport: class WsTransport { | ||
| 10 | dispose = vi.fn(async () => undefined); | ||
| 11 | reconnect = vi.fn(async () => undefined); | ||
| 12 | request = vi.fn(); | ||
| 13 | requestStream = vi.fn(); | ||
| 14 | subscribe = vi.fn(() => () => undefined); | ||
| 15 | }, | ||
| 16 | })); | ||
| 17 | |||
| 18 | import { createWsRpcClient } from "./wsRpcClient"; | ||
| 19 | import { type WsTransport } from "./wsTransport"; | ||
| 20 | |||
| 21 | const baseLocalStatus: VcsStatusLocalResult = { | ||
| 22 | isRepo: true, | ||
| 23 | hasPrimaryRemote: true, | ||
| 24 | isDefaultRef: false, | ||
| 25 | refName: "feature/demo", | ||
| 26 | hasWorkingTreeChanges: false, | ||
| 27 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 28 | }; | ||
| 29 | |||
| 30 | const baseRemoteStatus: VcsStatusRemoteResult = { | ||
| 31 | hasUpstream: true, | ||
| 32 | aheadCount: 0, | ||
| 33 | behindCount: 0, | ||
| 34 | pr: null, | ||
| 35 | }; | ||
| 36 | |||
| 37 | describe("wsRpcClient", () => { | ||
| 38 | it("reduces vcs status stream events into flat status snapshots", () => { | ||
| 39 | const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => { | ||
| 40 | for (const event of [ | ||
| 41 | { | ||
| 42 | _tag: "snapshot", | ||
| 43 | local: baseLocalStatus, | ||
| 44 | remote: null, | ||
| 45 | }, | ||
| 46 | { | ||
| 47 | _tag: "remoteUpdated", | ||
| 48 | remote: baseRemoteStatus, | ||
| 49 | }, | ||
| 50 | { | ||
| 51 | _tag: "localUpdated", | ||
| 52 | local: { | ||
| 53 | ...baseLocalStatus, | ||
| 54 | hasWorkingTreeChanges: true, | ||
| 55 | }, | ||
| 56 | }, | ||
| 57 | ] satisfies VcsStatusStreamEvent[]) { | ||
| 58 | listener(event as TValue); | ||
| 59 | } | ||
| 60 | return () => undefined; | ||
| 61 | }); | ||
| 62 | |||
| 63 | const transport = { | ||
| 64 | dispose: vi.fn(async () => undefined), | ||
| 65 | reconnect: vi.fn(async () => undefined), | ||
| 66 | request: vi.fn(), | ||
| 67 | requestStream: vi.fn(), | ||
| 68 | subscribe, | ||
| 69 | } satisfies Pick< | ||
| 70 | WsTransport, | ||
| 71 | "dispose" | "reconnect" | "request" | "requestStream" | "subscribe" | ||
| 72 | >; | ||
| 73 | |||
| 74 | const client = createWsRpcClient(transport as unknown as WsTransport); | ||
| 75 | const listener = vi.fn(); | ||
| 76 | |||
| 77 | client.vcs.onStatus({ cwd: "/repo" }, listener); | ||
| 78 | |||
| 79 | expect(listener.mock.calls).toEqual([ | ||
| 80 | [ | ||
| 81 | { | ||
| 82 | ...baseLocalStatus, | ||
| 83 | hasUpstream: false, | ||
| 84 | aheadCount: 0, | ||
| 85 | behindCount: 0, | ||
| 86 | aheadOfDefaultCount: 0, | ||
| 87 | pr: null, | ||
| 88 | }, | ||
| 89 | ], | ||
| 90 | [ | ||
| 91 | { | ||
| 92 | ...baseLocalStatus, | ||
| 93 | ...baseRemoteStatus, | ||
| 94 | }, | ||
| 95 | ], | ||
| 96 | [ | ||
| 97 | { | ||
| 98 | ...baseLocalStatus, | ||
| 99 | ...baseRemoteStatus, | ||
| 100 | hasWorkingTreeChanges: true, | ||
| 101 | }, | ||
| 102 | ], | ||
| 103 | ]); | ||
| 104 | }); | ||
| 105 | }); | ||
apps/web/src/rpc/wsRpcClient.ts deleted-323| ... | @@ -1,323 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type GitActionProgressEvent, | ||
| 3 | type GitRunStackedActionInput, | ||
| 4 | type GitRunStackedActionResult, | ||
| 5 | type VcsStatusResult, | ||
| 6 | type VcsStatusStreamEvent, | ||
| 7 | type LocalApi, | ||
| 8 | ORCHESTRATION_WS_METHODS, | ||
| 9 | type ServerSettingsPatch, | ||
| 10 | WS_METHODS, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Stream from "effect/Stream"; | ||
| 15 | |||
| 16 | import { type WsRpcProtocolClient } from "./protocol"; | ||
| 17 | import { resetWsReconnectBackoff } from "./wsConnectionState"; | ||
| 18 | import { WsTransport } from "./wsTransport"; | ||
| 19 | |||
| 20 | type RpcTag = keyof WsRpcProtocolClient & string; | ||
| 21 | type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag]; | ||
| 22 | type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0]; | ||
| 23 | |||
| 24 | interface StreamSubscriptionOptions { | ||
| 25 | readonly onResubscribe?: () => void; | ||
| 26 | } | ||
| 27 | |||
| 28 | type RpcUnaryMethod<TTag extends RpcTag> = | ||
| 29 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | ||
| 30 | ? (input: RpcInput<TTag>) => Promise<TSuccess> | ||
| 31 | : never; | ||
| 32 | |||
| 33 | type RpcUnaryNoArgMethod<TTag extends RpcTag> = | ||
| 34 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | ||
| 35 | ? () => Promise<TSuccess> | ||
| 36 | : never; | ||
| 37 | |||
| 38 | type RpcStreamMethod<TTag extends RpcTag> = | ||
| 39 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any> | ||
| 40 | ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void | ||
| 41 | : never; | ||
| 42 | |||
| 43 | type RpcInputStreamMethod<TTag extends RpcTag> = | ||
| 44 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any> | ||
| 45 | ? ( | ||
| 46 | input: RpcInput<TTag>, | ||
| 47 | listener: (event: TEvent) => void, | ||
| 48 | options?: StreamSubscriptionOptions, | ||
| 49 | ) => () => void | ||
| 50 | : never; | ||
| 51 | |||
| 52 | interface GitRunStackedActionOptions { | ||
| 53 | readonly onProgress?: (event: GitActionProgressEvent) => void; | ||
| 54 | } | ||
| 55 | |||
| 56 | export interface WsRpcClient { | ||
| 57 | readonly dispose: () => Promise<void>; | ||
| 58 | readonly reconnect: () => Promise<void>; | ||
| 59 | readonly isHeartbeatFresh: () => boolean; | ||
| 60 | readonly terminal: { | ||
| 61 | readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>; | ||
| 62 | readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>; | ||
| 63 | readonly resize: RpcUnaryMethod<typeof WS_METHODS.terminalResize>; | ||
| 64 | readonly clear: RpcUnaryMethod<typeof WS_METHODS.terminalClear>; | ||
| 65 | readonly restart: RpcUnaryMethod<typeof WS_METHODS.terminalRestart>; | ||
| 66 | readonly close: RpcUnaryMethod<typeof WS_METHODS.terminalClose>; | ||
| 67 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>; | ||
| 68 | }; | ||
| 69 | readonly projects: { | ||
| 70 | readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>; | ||
| 71 | readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>; | ||
| 72 | }; | ||
| 73 | readonly filesystem: { | ||
| 74 | readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>; | ||
| 75 | }; | ||
| 76 | readonly sourceControl: { | ||
| 77 | readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>; | ||
| 78 | readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>; | ||
| 79 | readonly publishRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlPublishRepository>; | ||
| 80 | }; | ||
| 81 | readonly shell: { | ||
| 82 | readonly openInEditor: (input: { | ||
| 83 | readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0]; | ||
| 84 | readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1]; | ||
| 85 | }) => ReturnType<LocalApi["shell"]["openInEditor"]>; | ||
| 86 | }; | ||
| 87 | readonly vcs: { | ||
| 88 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.vcsPull>; | ||
| 89 | readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.vcsRefreshStatus>; | ||
| 90 | readonly onStatus: ( | ||
| 91 | input: RpcInput<typeof WS_METHODS.subscribeVcsStatus>, | ||
| 92 | listener: (status: VcsStatusResult) => void, | ||
| 93 | options?: StreamSubscriptionOptions, | ||
| 94 | ) => () => void; | ||
| 95 | readonly listRefs: RpcUnaryMethod<typeof WS_METHODS.vcsListRefs>; | ||
| 96 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsCreateWorktree>; | ||
| 97 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsRemoveWorktree>; | ||
| 98 | readonly createRef: RpcUnaryMethod<typeof WS_METHODS.vcsCreateRef>; | ||
| 99 | readonly switchRef: RpcUnaryMethod<typeof WS_METHODS.vcsSwitchRef>; | ||
| 100 | readonly init: RpcUnaryMethod<typeof WS_METHODS.vcsInit>; | ||
| 101 | }; | ||
| 102 | /** | ||
| 103 | * Git-specific workflows. Local repository mechanics live under `vcs`. | ||
| 104 | */ | ||
| 105 | readonly git: { | ||
| 106 | readonly runStackedAction: ( | ||
| 107 | input: GitRunStackedActionInput, | ||
| 108 | options?: GitRunStackedActionOptions, | ||
| 109 | ) => Promise<GitRunStackedActionResult>; | ||
| 110 | readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>; | ||
| 111 | readonly preparePullRequestThread: RpcUnaryMethod< | ||
| 112 | typeof WS_METHODS.gitPreparePullRequestThread | ||
| 113 | >; | ||
| 114 | }; | ||
| 115 | readonly server: { | ||
| 116 | readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>; | ||
| 117 | /** | ||
| 118 | * Refresh provider snapshots. Pass `{ instanceId }` to refresh a single | ||
| 119 | * configured instance; pass no argument (or `{}`) to refresh all. | ||
| 120 | */ | ||
| 121 | readonly refreshProviders: ( | ||
| 122 | input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>, | ||
| 123 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>; | ||
| 124 | readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>; | ||
| 125 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; | ||
| 126 | readonly removeKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverRemoveKeybinding>; | ||
| 127 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; | ||
| 128 | readonly updateSettings: ( | ||
| 129 | patch: ServerSettingsPatch, | ||
| 130 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>; | ||
| 131 | readonly discoverSourceControl: RpcUnaryNoArgMethod< | ||
| 132 | typeof WS_METHODS.serverDiscoverSourceControl | ||
| 133 | >; | ||
| 134 | readonly getTraceDiagnostics: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetTraceDiagnostics>; | ||
| 135 | readonly getProcessDiagnostics: RpcUnaryNoArgMethod< | ||
| 136 | typeof WS_METHODS.serverGetProcessDiagnostics | ||
| 137 | >; | ||
| 138 | readonly getProcessResourceHistory: RpcUnaryMethod< | ||
| 139 | typeof WS_METHODS.serverGetProcessResourceHistory | ||
| 140 | >; | ||
| 141 | readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>; | ||
| 142 | readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>; | ||
| 143 | readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>; | ||
| 144 | readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>; | ||
| 145 | }; | ||
| 146 | readonly orchestration: { | ||
| 147 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; | ||
| 148 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; | ||
| 149 | readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>; | ||
| 150 | readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod< | ||
| 151 | typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot | ||
| 152 | >; | ||
| 153 | readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>; | ||
| 154 | readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>; | ||
| 155 | }; | ||
| 156 | } | ||
| 157 | |||
| 158 | export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ||
| 159 | return { | ||
| 160 | dispose: () => transport.dispose(), | ||
| 161 | reconnect: async () => { | ||
| 162 | resetWsReconnectBackoff(); | ||
| 163 | await transport.reconnect(); | ||
| 164 | }, | ||
| 165 | isHeartbeatFresh: () => transport.isHeartbeatFresh(), | ||
| 166 | terminal: { | ||
| 167 | open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), | ||
| 168 | write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), | ||
| 169 | resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), | ||
| 170 | clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), | ||
| 171 | restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), | ||
| 172 | close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), | ||
| 173 | onEvent: (listener, options) => | ||
| 174 | transport.subscribe((client) => client[WS_METHODS.subscribeTerminalEvents]({}), listener, { | ||
| 175 | ...options, | ||
| 176 | tag: WS_METHODS.subscribeTerminalEvents, | ||
| 177 | }), | ||
| 178 | }, | ||
| 179 | projects: { | ||
| 180 | searchEntries: (input) => | ||
| 181 | transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), | ||
| 182 | writeFile: (input) => | ||
| 183 | transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), | ||
| 184 | }, | ||
| 185 | filesystem: { | ||
| 186 | browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), | ||
| 187 | }, | ||
| 188 | sourceControl: { | ||
| 189 | lookupRepository: (input) => | ||
| 190 | transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), | ||
| 191 | cloneRepository: (input) => | ||
| 192 | transport.request((client) => client[WS_METHODS.sourceControlCloneRepository](input)), | ||
| 193 | publishRepository: (input) => | ||
| 194 | transport.request((client) => client[WS_METHODS.sourceControlPublishRepository](input)), | ||
| 195 | }, | ||
| 196 | shell: { | ||
| 197 | openInEditor: (input) => | ||
| 198 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), | ||
| 199 | }, | ||
| 200 | vcs: { | ||
| 201 | pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)), | ||
| 202 | refreshStatus: (input) => | ||
| 203 | transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)), | ||
| 204 | onStatus: (input, listener, options) => { | ||
| 205 | let current: VcsStatusResult | null = null; | ||
| 206 | return transport.subscribe( | ||
| 207 | (client) => client[WS_METHODS.subscribeVcsStatus](input), | ||
| 208 | (event: VcsStatusStreamEvent) => { | ||
| 209 | current = applyGitStatusStreamEvent(current, event); | ||
| 210 | listener(current); | ||
| 211 | }, | ||
| 212 | { ...options, tag: WS_METHODS.subscribeVcsStatus }, | ||
| 213 | ); | ||
| 214 | }, | ||
| 215 | listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), | ||
| 216 | createWorktree: (input) => | ||
| 217 | transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), | ||
| 218 | removeWorktree: (input) => | ||
| 219 | transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), | ||
| 220 | createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), | ||
| 221 | switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), | ||
| 222 | init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), | ||
| 223 | }, | ||
| 224 | git: { | ||
| 225 | runStackedAction: async (input, options) => { | ||
| 226 | let result: GitRunStackedActionResult | null = null; | ||
| 227 | |||
| 228 | await transport.requestStream( | ||
| 229 | (client) => client[WS_METHODS.gitRunStackedAction](input), | ||
| 230 | (event) => { | ||
| 231 | options?.onProgress?.(event); | ||
| 232 | if (event.kind === "action_finished") { | ||
| 233 | result = event.result; | ||
| 234 | } | ||
| 235 | }, | ||
| 236 | ); | ||
| 237 | |||
| 238 | if (result) { | ||
| 239 | return result; | ||
| 240 | } | ||
| 241 | |||
| 242 | throw new Error("Git action stream completed without a final result."); | ||
| 243 | }, | ||
| 244 | resolvePullRequest: (input) => | ||
| 245 | transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), | ||
| 246 | preparePullRequestThread: (input) => | ||
| 247 | transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), | ||
| 248 | }, | ||
| 249 | server: { | ||
| 250 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), | ||
| 251 | refreshProviders: (input) => | ||
| 252 | transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), | ||
| 253 | updateProvider: (input) => | ||
| 254 | transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)), | ||
| 255 | upsertKeybinding: (input) => | ||
| 256 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), | ||
| 257 | removeKeybinding: (input) => | ||
| 258 | transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)), | ||
| 259 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), | ||
| 260 | updateSettings: (patch) => | ||
| 261 | transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), | ||
| 262 | discoverSourceControl: () => | ||
| 263 | transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), | ||
| 264 | getTraceDiagnostics: () => | ||
| 265 | transport.request((client) => | ||
| 266 | client[WS_METHODS.serverGetTraceDiagnostics]({}).pipe(Effect.withTracerEnabled(false)), | ||
| 267 | ), | ||
| 268 | getProcessDiagnostics: () => | ||
| 269 | transport.request((client) => | ||
| 270 | client[WS_METHODS.serverGetProcessDiagnostics]({}).pipe(Effect.withTracerEnabled(false)), | ||
| 271 | ), | ||
| 272 | getProcessResourceHistory: (input) => | ||
| 273 | transport.request((client) => | ||
| 274 | client[WS_METHODS.serverGetProcessResourceHistory](input).pipe( | ||
| 275 | Effect.withTracerEnabled(false), | ||
| 276 | ), | ||
| 277 | ), | ||
| 278 | signalProcess: (input) => | ||
| 279 | transport.request((client) => | ||
| 280 | client[WS_METHODS.serverSignalProcess](input).pipe(Effect.withTracerEnabled(false)), | ||
| 281 | ), | ||
| 282 | subscribeConfig: (listener, options) => | ||
| 283 | transport.subscribe((client) => client[WS_METHODS.subscribeServerConfig]({}), listener, { | ||
| 284 | ...options, | ||
| 285 | tag: WS_METHODS.subscribeServerConfig, | ||
| 286 | }), | ||
| 287 | subscribeLifecycle: (listener, options) => | ||
| 288 | transport.subscribe((client) => client[WS_METHODS.subscribeServerLifecycle]({}), listener, { | ||
| 289 | ...options, | ||
| 290 | tag: WS_METHODS.subscribeServerLifecycle, | ||
| 291 | }), | ||
| 292 | subscribeAuthAccess: (listener, options) => | ||
| 293 | transport.subscribe((client) => client[WS_METHODS.subscribeAuthAccess]({}), listener, { | ||
| 294 | ...options, | ||
| 295 | tag: WS_METHODS.subscribeAuthAccess, | ||
| 296 | }), | ||
| 297 | }, | ||
| 298 | orchestration: { | ||
| 299 | dispatchCommand: (input) => | ||
| 300 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), | ||
| 301 | getTurnDiff: (input) => | ||
| 302 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), | ||
| 303 | getFullThreadDiff: (input) => | ||
| 304 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), | ||
| 305 | getArchivedShellSnapshot: () => | ||
| 306 | transport.request((client) => | ||
| 307 | client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}), | ||
| 308 | ), | ||
| 309 | subscribeShell: (listener, options) => | ||
| 310 | transport.subscribe( | ||
| 311 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), | ||
| 312 | listener, | ||
| 313 | { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeShell }, | ||
| 314 | ), | ||
| 315 | subscribeThread: (input, listener, options) => | ||
| 316 | transport.subscribe( | ||
| 317 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), | ||
| 318 | listener, | ||
| 319 | { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeThread }, | ||
| 320 | ), | ||
| 321 | }, | ||
| 322 | }; | ||
| 323 | } | ||
apps/web/src/rpc/wsTransport.test.ts+1-826| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts"; | 1 | import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | import * as Stream from "effect/Stream"; | ||
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 5 | 4 | ||
| 6 | import { | 5 | import { |
| ... | @@ -155,51 +154,7 @@ afterEach(async () => { | ... | @@ -155,51 +154,7 @@ afterEach(async () => { |
| 155 | vi.restoreAllMocks(); | 154 | vi.restoreAllMocks(); |
| 156 | }); | 155 | }); |
| 157 | 156 | ||
| 158 | describe("WsTransport", () => { | 157 | describe("WsTransport (web instrumentation)", () => { |
| 159 | it("normalizes root websocket urls to /ws and preserves query params", async () => { | ||
| 160 | const transport = createTransport("ws://localhost:3020/?token=secret-token"); | ||
| 161 | |||
| 162 | await waitFor(() => { | ||
| 163 | expect(sockets).toHaveLength(1); | ||
| 164 | }); | ||
| 165 | |||
| 166 | expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); | ||
| 167 | await transport.dispose(); | ||
| 168 | }); | ||
| 169 | |||
| 170 | it("uses an explicit secure websocket base url", async () => { | ||
| 171 | const transport = createTransport("wss://app.example.com"); | ||
| 172 | |||
| 173 | await waitFor(() => { | ||
| 174 | expect(sockets).toHaveLength(1); | ||
| 175 | }); | ||
| 176 | |||
| 177 | expect(getSocket().url).toBe("wss://app.example.com/ws"); | ||
| 178 | await transport.dispose(); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it("uses an explicit insecure websocket base url for remote backends", async () => { | ||
| 182 | const transport = createTransport("ws://192.168.1.44:3773"); | ||
| 183 | |||
| 184 | await waitFor(() => { | ||
| 185 | expect(sockets).toHaveLength(1); | ||
| 186 | }); | ||
| 187 | |||
| 188 | expect(getSocket().url).toBe("ws://192.168.1.44:3773/ws"); | ||
| 189 | await transport.dispose(); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("supports async websocket url providers", async () => { | ||
| 193 | const transport = createTransport(async () => "wss://remote.example.com/?wsToken=dynamic"); | ||
| 194 | |||
| 195 | await waitFor(() => { | ||
| 196 | expect(sockets).toHaveLength(1); | ||
| 197 | }); | ||
| 198 | |||
| 199 | expect(getSocket().url).toBe("wss://remote.example.com/ws?wsToken=dynamic"); | ||
| 200 | await transport.dispose(); | ||
| 201 | }); | ||
| 202 | |||
| 203 | it("tracks initial connection failures for the app error state", async () => { | 158 | it("tracks initial connection failures for the app error state", async () => { |
| 204 | const transport = createTransport("ws://localhost:3020"); | 159 | const transport = createTransport("ws://localhost:3020"); |
| 205 | 160 | ||
| ... | @@ -306,192 +261,6 @@ describe("WsTransport", () => { | ... | @@ -306,192 +261,6 @@ describe("WsTransport", () => { |
| 306 | await transport.dispose(); | 261 | await transport.dispose(); |
| 307 | }); | 262 | }); |
| 308 | 263 | ||
| 309 | it("does not report an intentional dispose as a reconnectable disconnect", async () => { | ||
| 310 | const onClose = vi.fn(); | ||
| 311 | const transport = createTransport("ws://localhost:3020", { | ||
| 312 | getConnectionLabel: () => "Remote Mac mini", | ||
| 313 | onClose, | ||
| 314 | }); | ||
| 315 | |||
| 316 | await waitFor(() => { | ||
| 317 | expect(sockets).toHaveLength(1); | ||
| 318 | }); | ||
| 319 | |||
| 320 | const socket = getSocket(); | ||
| 321 | socket.open(); | ||
| 322 | |||
| 323 | await waitFor(() => { | ||
| 324 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 325 | connectionLabel: "Remote Mac mini", | ||
| 326 | hasConnected: true, | ||
| 327 | phase: "connected", | ||
| 328 | }); | ||
| 329 | }); | ||
| 330 | |||
| 331 | await transport.dispose(); | ||
| 332 | |||
| 333 | expect(onClose).not.toHaveBeenCalled(); | ||
| 334 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 335 | connectionLabel: "Remote Mac mini", | ||
| 336 | phase: "connected", | ||
| 337 | reconnectPhase: "idle", | ||
| 338 | }); | ||
| 339 | }); | ||
| 340 | |||
| 341 | it("reconnects the websocket session without disposing the transport", async () => { | ||
| 342 | const transport = createTransport("ws://localhost:3020"); | ||
| 343 | |||
| 344 | await waitFor(() => { | ||
| 345 | expect(sockets).toHaveLength(1); | ||
| 346 | }); | ||
| 347 | |||
| 348 | const firstSocket = getSocket(); | ||
| 349 | firstSocket.open(); | ||
| 350 | |||
| 351 | await waitFor(() => { | ||
| 352 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 353 | hasConnected: true, | ||
| 354 | phase: "connected", | ||
| 355 | }); | ||
| 356 | }); | ||
| 357 | |||
| 358 | await transport.reconnect(); | ||
| 359 | |||
| 360 | await waitFor(() => { | ||
| 361 | expect(sockets).toHaveLength(2); | ||
| 362 | }); | ||
| 363 | |||
| 364 | const secondSocket = getSocket(); | ||
| 365 | expect(secondSocket).not.toBe(firstSocket); | ||
| 366 | expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); | ||
| 367 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 368 | closeCode: null, | ||
| 369 | closeReason: null, | ||
| 370 | phase: "connecting", | ||
| 371 | }); | ||
| 372 | |||
| 373 | const requestPromise = transport.request((client) => | ||
| 374 | client[WS_METHODS.serverUpsertKeybinding]({ | ||
| 375 | command: "terminal.toggle", | ||
| 376 | key: "ctrl+k", | ||
| 377 | }), | ||
| 378 | ); | ||
| 379 | |||
| 380 | secondSocket.open(); | ||
| 381 | |||
| 382 | await waitFor(() => { | ||
| 383 | expect(secondSocket.sent).toHaveLength(1); | ||
| 384 | }); | ||
| 385 | |||
| 386 | const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string }; | ||
| 387 | secondSocket.serverMessage( | ||
| 388 | JSON.stringify({ | ||
| 389 | _tag: "Exit", | ||
| 390 | requestId: requestMessage.id, | ||
| 391 | exit: { | ||
| 392 | _tag: "Success", | ||
| 393 | value: { | ||
| 394 | keybindings: [], | ||
| 395 | issues: [], | ||
| 396 | }, | ||
| 397 | }, | ||
| 398 | }), | ||
| 399 | ); | ||
| 400 | |||
| 401 | await expect(requestPromise).resolves.toEqual({ | ||
| 402 | keybindings: [], | ||
| 403 | issues: [], | ||
| 404 | }); | ||
| 405 | |||
| 406 | await transport.dispose(); | ||
| 407 | }); | ||
| 408 | |||
| 409 | it("ignores stale socket lifecycle events after a reconnect starts a new session", async () => { | ||
| 410 | const onClose = vi.fn(); | ||
| 411 | const transport = createTransport("ws://localhost:3020", { onClose }); | ||
| 412 | |||
| 413 | await waitFor(() => { | ||
| 414 | expect(sockets).toHaveLength(1); | ||
| 415 | }); | ||
| 416 | |||
| 417 | const firstSocket = getSocket(); | ||
| 418 | firstSocket.open(); | ||
| 419 | |||
| 420 | await waitFor(() => { | ||
| 421 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 422 | hasConnected: true, | ||
| 423 | phase: "connected", | ||
| 424 | }); | ||
| 425 | }); | ||
| 426 | |||
| 427 | await transport.reconnect(); | ||
| 428 | |||
| 429 | await waitFor(() => { | ||
| 430 | expect(sockets).toHaveLength(2); | ||
| 431 | }); | ||
| 432 | |||
| 433 | expect(onClose).not.toHaveBeenCalled(); | ||
| 434 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 435 | closeCode: null, | ||
| 436 | closeReason: null, | ||
| 437 | phase: "connecting", | ||
| 438 | }); | ||
| 439 | |||
| 440 | const secondSocket = getSocket(); | ||
| 441 | secondSocket.open(); | ||
| 442 | |||
| 443 | await waitFor(() => { | ||
| 444 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 445 | phase: "connected", | ||
| 446 | }); | ||
| 447 | }); | ||
| 448 | |||
| 449 | firstSocket.close(1006, "stale close"); | ||
| 450 | |||
| 451 | expect(onClose).not.toHaveBeenCalled(); | ||
| 452 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 453 | closeCode: null, | ||
| 454 | closeReason: null, | ||
| 455 | phase: "connected", | ||
| 456 | }); | ||
| 457 | |||
| 458 | await transport.dispose(); | ||
| 459 | }); | ||
| 460 | |||
| 461 | it("ignores stale socket close events after the transport is disposed", async () => { | ||
| 462 | const onClose = vi.fn(); | ||
| 463 | const transport = createTransport("ws://localhost:3020", { onClose }); | ||
| 464 | |||
| 465 | await waitFor(() => { | ||
| 466 | expect(sockets).toHaveLength(1); | ||
| 467 | }); | ||
| 468 | |||
| 469 | const firstSocket = getSocket(); | ||
| 470 | firstSocket.open(); | ||
| 471 | |||
| 472 | await waitFor(() => { | ||
| 473 | expect(getWsConnectionStatus()).toMatchObject({ | ||
| 474 | hasConnected: true, | ||
| 475 | phase: "connected", | ||
| 476 | }); | ||
| 477 | }); | ||
| 478 | |||
| 479 | await transport.reconnect(); | ||
| 480 | |||
| 481 | await waitFor(() => { | ||
| 482 | expect(sockets).toHaveLength(2); | ||
| 483 | }); | ||
| 484 | |||
| 485 | const secondSocket = getSocket(); | ||
| 486 | secondSocket.open(); | ||
| 487 | await transport.dispose(); | ||
| 488 | |||
| 489 | onClose.mockClear(); | ||
| 490 | firstSocket.close(1006, "stale close after dispose"); | ||
| 491 | |||
| 492 | expect(onClose).not.toHaveBeenCalled(); | ||
| 493 | }); | ||
| 494 | |||
| 495 | it("marks unary requests as slow until the first server ack arrives", async () => { | 264 | it("marks unary requests as slow until the first server ack arrives", async () => { |
| 496 | const slowAckThresholdMs = 25; | 265 | const slowAckThresholdMs = 25; |
| 497 | setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); | 266 | setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); |
| ... | @@ -598,600 +367,6 @@ describe("WsTransport", () => { | ... | @@ -598,600 +367,6 @@ describe("WsTransport", () => { |
| 598 | await transport.dispose(); | 367 | await transport.dispose(); |
| 599 | }, 5_000); | 368 | }, 5_000); |
| 600 | 369 | ||
| 601 | it("sends unary RPC requests and resolves successful exits", async () => { | ||
| 602 | const transport = createTransport("ws://localhost:3020"); | ||
| 603 | |||
| 604 | const requestPromise = transport.request((client) => | ||
| 605 | client[WS_METHODS.serverUpsertKeybinding]({ | ||
| 606 | command: "terminal.toggle", | ||
| 607 | key: "ctrl+k", | ||
| 608 | }), | ||
| 609 | ); | ||
| 610 | |||
| 611 | await waitFor(() => { | ||
| 612 | expect(sockets).toHaveLength(1); | ||
| 613 | }); | ||
| 614 | |||
| 615 | const socket = getSocket(); | ||
| 616 | socket.open(); | ||
| 617 | |||
| 618 | await waitFor(() => { | ||
| 619 | expect(socket.sent).toHaveLength(1); | ||
| 620 | }); | ||
| 621 | |||
| 622 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | ||
| 623 | _tag: string; | ||
| 624 | id: string; | ||
| 625 | payload: unknown; | ||
| 626 | tag: string; | ||
| 627 | }; | ||
| 628 | expect(requestMessage).toMatchObject({ | ||
| 629 | _tag: "Request", | ||
| 630 | tag: WS_METHODS.serverUpsertKeybinding, | ||
| 631 | payload: { | ||
| 632 | command: "terminal.toggle", | ||
| 633 | key: "ctrl+k", | ||
| 634 | }, | ||
| 635 | }); | ||
| 636 | |||
| 637 | socket.serverMessage( | ||
| 638 | JSON.stringify({ | ||
| 639 | _tag: "Exit", | ||
| 640 | requestId: requestMessage.id, | ||
| 641 | exit: { | ||
| 642 | _tag: "Success", | ||
| 643 | value: { | ||
| 644 | keybindings: [], | ||
| 645 | issues: [], | ||
| 646 | }, | ||
| 647 | }, | ||
| 648 | }), | ||
| 649 | ); | ||
| 650 | |||
| 651 | await expect(requestPromise).resolves.toEqual({ | ||
| 652 | keybindings: [], | ||
| 653 | issues: [], | ||
| 654 | }); | ||
| 655 | |||
| 656 | await transport.dispose(); | ||
| 657 | }); | ||
| 658 | |||
| 659 | it("delivers stream chunks to subscribers", async () => { | ||
| 660 | const transport = createTransport("ws://localhost:3020"); | ||
| 661 | const listener = vi.fn(); | ||
| 662 | |||
| 663 | const unsubscribe = transport.subscribe( | ||
| 664 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 665 | listener, | ||
| 666 | ); | ||
| 667 | await waitFor(() => { | ||
| 668 | expect(sockets).toHaveLength(1); | ||
| 669 | }); | ||
| 670 | |||
| 671 | const socket = getSocket(); | ||
| 672 | socket.open(); | ||
| 673 | |||
| 674 | await waitFor(() => { | ||
| 675 | expect(socket.sent).toHaveLength(1); | ||
| 676 | }); | ||
| 677 | |||
| 678 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | ||
| 679 | expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); | ||
| 680 | |||
| 681 | const welcomeEvent = { | ||
| 682 | version: 1, | ||
| 683 | sequence: 1, | ||
| 684 | type: "welcome", | ||
| 685 | payload: { | ||
| 686 | environment: { | ||
| 687 | environmentId: "environment-local", | ||
| 688 | label: "Local environment", | ||
| 689 | platform: { os: "darwin", arch: "arm64" }, | ||
| 690 | serverVersion: "0.0.0-test", | ||
| 691 | capabilities: { repositoryIdentity: true }, | ||
| 692 | }, | ||
| 693 | cwd: "/tmp/workspace", | ||
| 694 | projectName: "workspace", | ||
| 695 | }, | ||
| 696 | }; | ||
| 697 | |||
| 698 | socket.serverMessage( | ||
| 699 | JSON.stringify({ | ||
| 700 | _tag: "Chunk", | ||
| 701 | requestId: requestMessage.id, | ||
| 702 | values: [welcomeEvent], | ||
| 703 | }), | ||
| 704 | ); | ||
| 705 | |||
| 706 | await waitFor(() => { | ||
| 707 | expect(listener).toHaveBeenCalledWith(welcomeEvent); | ||
| 708 | }); | ||
| 709 | |||
| 710 | unsubscribe(); | ||
| 711 | await transport.dispose(); | ||
| 712 | }); | ||
| 713 | |||
| 714 | it("re-subscribes stream listeners after the stream exits", async () => { | ||
| 715 | const transport = createTransport("ws://localhost:3020"); | ||
| 716 | const listener = vi.fn(); | ||
| 717 | const onResubscribe = vi.fn(); | ||
| 718 | |||
| 719 | const unsubscribe = transport.subscribe( | ||
| 720 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 721 | listener, | ||
| 722 | { onResubscribe }, | ||
| 723 | ); | ||
| 724 | await waitFor(() => { | ||
| 725 | expect(sockets).toHaveLength(1); | ||
| 726 | }); | ||
| 727 | |||
| 728 | const socket = getSocket(); | ||
| 729 | socket.open(); | ||
| 730 | |||
| 731 | await waitFor(() => { | ||
| 732 | expect(socket.sent).toHaveLength(1); | ||
| 733 | }); | ||
| 734 | |||
| 735 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 736 | socket.serverMessage( | ||
| 737 | JSON.stringify({ | ||
| 738 | _tag: "Chunk", | ||
| 739 | requestId: firstRequest.id, | ||
| 740 | values: [ | ||
| 741 | { | ||
| 742 | version: 1, | ||
| 743 | sequence: 1, | ||
| 744 | type: "welcome", | ||
| 745 | payload: { | ||
| 746 | environment: { | ||
| 747 | environmentId: "environment-local", | ||
| 748 | label: "Local environment", | ||
| 749 | platform: { os: "darwin", arch: "arm64" }, | ||
| 750 | serverVersion: "0.0.0-test", | ||
| 751 | capabilities: { repositoryIdentity: true }, | ||
| 752 | }, | ||
| 753 | cwd: "/tmp/one", | ||
| 754 | projectName: "one", | ||
| 755 | }, | ||
| 756 | }, | ||
| 757 | ], | ||
| 758 | }), | ||
| 759 | ); | ||
| 760 | socket.serverMessage( | ||
| 761 | JSON.stringify({ | ||
| 762 | _tag: "Exit", | ||
| 763 | requestId: firstRequest.id, | ||
| 764 | exit: { | ||
| 765 | _tag: "Success", | ||
| 766 | value: null, | ||
| 767 | }, | ||
| 768 | }), | ||
| 769 | ); | ||
| 770 | |||
| 771 | await waitFor(() => { | ||
| 772 | const nextRequest = socket.sent | ||
| 773 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | ||
| 774 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | ||
| 775 | expect(nextRequest).toBeDefined(); | ||
| 776 | }); | ||
| 777 | expect(onResubscribe).toHaveBeenCalledOnce(); | ||
| 778 | |||
| 779 | const secondRequest = socket.sent | ||
| 780 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) | ||
| 781 | .find( | ||
| 782 | (message): message is { _tag: "Request"; id: string; tag: string } => | ||
| 783 | message._tag === "Request" && message.id !== firstRequest.id, | ||
| 784 | ); | ||
| 785 | if (!secondRequest) { | ||
| 786 | throw new Error("Expected a resubscribe request"); | ||
| 787 | } | ||
| 788 | expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); | ||
| 789 | expect(secondRequest.id).not.toBe(firstRequest.id); | ||
| 790 | |||
| 791 | const secondEvent = { | ||
| 792 | version: 1, | ||
| 793 | sequence: 2, | ||
| 794 | type: "welcome", | ||
| 795 | payload: { | ||
| 796 | environment: { | ||
| 797 | environmentId: "environment-local", | ||
| 798 | label: "Local environment", | ||
| 799 | platform: { os: "darwin", arch: "arm64" }, | ||
| 800 | serverVersion: "0.0.0-test", | ||
| 801 | capabilities: { repositoryIdentity: true }, | ||
| 802 | }, | ||
| 803 | cwd: "/tmp/two", | ||
| 804 | projectName: "two", | ||
| 805 | }, | ||
| 806 | }; | ||
| 807 | socket.serverMessage( | ||
| 808 | JSON.stringify({ | ||
| 809 | _tag: "Chunk", | ||
| 810 | requestId: secondRequest.id, | ||
| 811 | values: [secondEvent], | ||
| 812 | }), | ||
| 813 | ); | ||
| 814 | |||
| 815 | await waitFor(() => { | ||
| 816 | expect(listener).toHaveBeenLastCalledWith(secondEvent); | ||
| 817 | }); | ||
| 818 | |||
| 819 | unsubscribe(); | ||
| 820 | await transport.dispose(); | ||
| 821 | }); | ||
| 822 | |||
| 823 | it("re-subscribes live stream listeners after an explicit transport reconnect", async () => { | ||
| 824 | const transport = createTransport("ws://localhost:3020"); | ||
| 825 | const listener = vi.fn(); | ||
| 826 | const onResubscribe = vi.fn(); | ||
| 827 | |||
| 828 | const unsubscribe = transport.subscribe( | ||
| 829 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 830 | listener, | ||
| 831 | { onResubscribe }, | ||
| 832 | ); | ||
| 833 | |||
| 834 | await waitFor(() => { | ||
| 835 | expect(sockets).toHaveLength(1); | ||
| 836 | }); | ||
| 837 | |||
| 838 | const firstSocket = getSocket(); | ||
| 839 | firstSocket.open(); | ||
| 840 | |||
| 841 | await waitFor(() => { | ||
| 842 | expect(firstSocket.sent).toHaveLength(1); | ||
| 843 | }); | ||
| 844 | |||
| 845 | const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; | ||
| 846 | const firstEvent = { | ||
| 847 | version: 1, | ||
| 848 | sequence: 1, | ||
| 849 | type: "welcome", | ||
| 850 | payload: { | ||
| 851 | environment: { | ||
| 852 | environmentId: "environment-local", | ||
| 853 | label: "Local environment", | ||
| 854 | platform: { os: "darwin", arch: "arm64" }, | ||
| 855 | serverVersion: "0.0.0-test", | ||
| 856 | capabilities: { repositoryIdentity: true }, | ||
| 857 | }, | ||
| 858 | cwd: "/tmp/one", | ||
| 859 | projectName: "one", | ||
| 860 | }, | ||
| 861 | }; | ||
| 862 | |||
| 863 | firstSocket.serverMessage( | ||
| 864 | JSON.stringify({ | ||
| 865 | _tag: "Chunk", | ||
| 866 | requestId: firstRequest.id, | ||
| 867 | values: [firstEvent], | ||
| 868 | }), | ||
| 869 | ); | ||
| 870 | |||
| 871 | await waitFor(() => { | ||
| 872 | expect(listener).toHaveBeenLastCalledWith(firstEvent); | ||
| 873 | }); | ||
| 874 | |||
| 875 | await transport.reconnect(); | ||
| 876 | |||
| 877 | await waitFor(() => { | ||
| 878 | expect(sockets).toHaveLength(2); | ||
| 879 | }); | ||
| 880 | |||
| 881 | const secondSocket = getSocket(); | ||
| 882 | expect(secondSocket).not.toBe(firstSocket); | ||
| 883 | expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); | ||
| 884 | |||
| 885 | secondSocket.open(); | ||
| 886 | |||
| 887 | await waitFor(() => { | ||
| 888 | expect(secondSocket.sent).toHaveLength(1); | ||
| 889 | }); | ||
| 890 | |||
| 891 | const secondRequest = JSON.parse(secondSocket.sent[0] ?? "{}") as { | ||
| 892 | id: string; | ||
| 893 | tag: string; | ||
| 894 | }; | ||
| 895 | expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); | ||
| 896 | expect(secondRequest.id).not.toBe(firstRequest.id); | ||
| 897 | expect(onResubscribe).toHaveBeenCalledOnce(); | ||
| 898 | |||
| 899 | const secondEvent = { | ||
| 900 | version: 1, | ||
| 901 | sequence: 2, | ||
| 902 | type: "welcome", | ||
| 903 | payload: { | ||
| 904 | environment: { | ||
| 905 | environmentId: "environment-local", | ||
| 906 | label: "Local environment", | ||
| 907 | platform: { os: "darwin", arch: "arm64" }, | ||
| 908 | serverVersion: "0.0.0-test", | ||
| 909 | capabilities: { repositoryIdentity: true }, | ||
| 910 | }, | ||
| 911 | cwd: "/tmp/two", | ||
| 912 | projectName: "two", | ||
| 913 | }, | ||
| 914 | }; | ||
| 915 | |||
| 916 | secondSocket.serverMessage( | ||
| 917 | JSON.stringify({ | ||
| 918 | _tag: "Chunk", | ||
| 919 | requestId: secondRequest.id, | ||
| 920 | values: [secondEvent], | ||
| 921 | }), | ||
| 922 | ); | ||
| 923 | |||
| 924 | await waitFor(() => { | ||
| 925 | expect(listener).toHaveBeenLastCalledWith(secondEvent); | ||
| 926 | }); | ||
| 927 | |||
| 928 | unsubscribe(); | ||
| 929 | await transport.dispose(); | ||
| 930 | }); | ||
| 931 | |||
| 932 | it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { | ||
| 933 | const transport = createTransport("ws://localhost:3020"); | ||
| 934 | const listener = vi.fn(); | ||
| 935 | const onResubscribe = vi.fn(); | ||
| 936 | |||
| 937 | const unsubscribe = transport.subscribe( | ||
| 938 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 939 | listener, | ||
| 940 | { onResubscribe }, | ||
| 941 | ); | ||
| 942 | await waitFor(() => { | ||
| 943 | expect(sockets).toHaveLength(1); | ||
| 944 | }); | ||
| 945 | |||
| 946 | const socket = getSocket(); | ||
| 947 | socket.open(); | ||
| 948 | |||
| 949 | await waitFor(() => { | ||
| 950 | expect(socket.sent).toHaveLength(1); | ||
| 951 | }); | ||
| 952 | |||
| 953 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 954 | socket.serverMessage( | ||
| 955 | JSON.stringify({ | ||
| 956 | _tag: "Exit", | ||
| 957 | requestId: firstRequest.id, | ||
| 958 | exit: { | ||
| 959 | _tag: "Success", | ||
| 960 | value: null, | ||
| 961 | }, | ||
| 962 | }), | ||
| 963 | ); | ||
| 964 | |||
| 965 | await waitFor(() => { | ||
| 966 | const nextRequest = socket.sent | ||
| 967 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | ||
| 968 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | ||
| 969 | expect(nextRequest).toBeDefined(); | ||
| 970 | }); | ||
| 971 | expect(onResubscribe).not.toHaveBeenCalled(); | ||
| 972 | expect(listener).not.toHaveBeenCalled(); | ||
| 973 | |||
| 974 | unsubscribe(); | ||
| 975 | await transport.dispose(); | ||
| 976 | }); | ||
| 977 | |||
| 978 | it("does not retry stream subscriptions after application-level failures", async () => { | ||
| 979 | const transport = createTransport("ws://localhost:3020"); | ||
| 980 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | ||
| 981 | let attempts = 0; | ||
| 982 | |||
| 983 | const unsubscribe = transport.subscribe( | ||
| 984 | () => | ||
| 985 | Stream.suspend(() => { | ||
| 986 | attempts += 1; | ||
| 987 | return Stream.fail(new Error("Git command failed in GitCore.statusDetails")); | ||
| 988 | }), | ||
| 989 | vi.fn(), | ||
| 990 | { retryDelay: 10 }, | ||
| 991 | ); | ||
| 992 | |||
| 993 | await waitFor(() => { | ||
| 994 | expect(sockets).toHaveLength(1); | ||
| 995 | }); | ||
| 996 | |||
| 997 | getSocket().open(); | ||
| 998 | |||
| 999 | await waitFor(() => { | ||
| 1000 | expect(attempts).toBe(1); | ||
| 1001 | }); | ||
| 1002 | await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| 1003 | |||
| 1004 | expect(attempts).toBe(1); | ||
| 1005 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription failed", { | ||
| 1006 | error: "Git command failed in GitCore.statusDetails", | ||
| 1007 | }); | ||
| 1008 | expect(warnSpy).not.toHaveBeenCalledWith( | ||
| 1009 | "WebSocket RPC subscription disconnected", | ||
| 1010 | expect.anything(), | ||
| 1011 | ); | ||
| 1012 | |||
| 1013 | unsubscribe(); | ||
| 1014 | await transport.dispose(); | ||
| 1015 | }); | ||
| 1016 | |||
| 1017 | it("keeps retrying stream subscriptions after transport failures", async () => { | ||
| 1018 | const transport = createTransport("ws://localhost:3020"); | ||
| 1019 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | ||
| 1020 | let attempts = 0; | ||
| 1021 | |||
| 1022 | const unsubscribe = transport.subscribe( | ||
| 1023 | () => | ||
| 1024 | Stream.suspend(() => { | ||
| 1025 | attempts += 1; | ||
| 1026 | return Stream.fail(new Error("SocketCloseError: WebSocket closed")); | ||
| 1027 | }), | ||
| 1028 | vi.fn(), | ||
| 1029 | { retryDelay: 10 }, | ||
| 1030 | ); | ||
| 1031 | |||
| 1032 | await waitFor(() => { | ||
| 1033 | expect(sockets).toHaveLength(1); | ||
| 1034 | }); | ||
| 1035 | |||
| 1036 | getSocket().open(); | ||
| 1037 | |||
| 1038 | await waitFor(() => { | ||
| 1039 | expect(attempts).toBeGreaterThanOrEqual(2); | ||
| 1040 | }); | ||
| 1041 | |||
| 1042 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { | ||
| 1043 | error: "SocketCloseError: WebSocket closed", | ||
| 1044 | }); | ||
| 1045 | |||
| 1046 | unsubscribe(); | ||
| 1047 | await transport.dispose(); | ||
| 1048 | }); | ||
| 1049 | |||
| 1050 | it("logs a transport disconnect once even when multiple subscriptions fail together", async () => { | ||
| 1051 | const transport = createTransport("ws://localhost:3020"); | ||
| 1052 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); | ||
| 1053 | |||
| 1054 | const unsubscribeA = transport.subscribe( | ||
| 1055 | () => Stream.fail(new Error("SocketCloseError: 1006")), | ||
| 1056 | vi.fn(), | ||
| 1057 | { retryDelay: 10 }, | ||
| 1058 | ); | ||
| 1059 | const unsubscribeB = transport.subscribe( | ||
| 1060 | () => Stream.fail(new Error("SocketCloseError: 1006")), | ||
| 1061 | vi.fn(), | ||
| 1062 | { retryDelay: 10 }, | ||
| 1063 | ); | ||
| 1064 | |||
| 1065 | await waitFor(() => { | ||
| 1066 | expect(sockets).toHaveLength(1); | ||
| 1067 | }); | ||
| 1068 | |||
| 1069 | getSocket().open(); | ||
| 1070 | |||
| 1071 | await waitFor(() => { | ||
| 1072 | expect(warnSpy).toHaveBeenCalledTimes(1); | ||
| 1073 | }); | ||
| 1074 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { | ||
| 1075 | error: "SocketCloseError: 1006", | ||
| 1076 | }); | ||
| 1077 | |||
| 1078 | unsubscribeA(); | ||
| 1079 | unsubscribeB(); | ||
| 1080 | await transport.dispose(); | ||
| 1081 | }); | ||
| 1082 | |||
| 1083 | it("streams finite request events without re-subscribing", async () => { | ||
| 1084 | const transport = createTransport("ws://localhost:3020"); | ||
| 1085 | const listener = vi.fn(); | ||
| 1086 | |||
| 1087 | await waitFor(() => { | ||
| 1088 | expect(sockets).toHaveLength(1); | ||
| 1089 | }); | ||
| 1090 | const socket = getSocket(); | ||
| 1091 | socket.open(); | ||
| 1092 | |||
| 1093 | const requestPromise = transport.requestStream( | ||
| 1094 | (client) => | ||
| 1095 | client[WS_METHODS.gitRunStackedAction]({ | ||
| 1096 | actionId: "action-1", | ||
| 1097 | cwd: "/repo", | ||
| 1098 | action: "commit", | ||
| 1099 | }), | ||
| 1100 | listener, | ||
| 1101 | ); | ||
| 1102 | |||
| 1103 | await waitFor(() => { | ||
| 1104 | expect(socket.sent).toHaveLength(1); | ||
| 1105 | }); | ||
| 1106 | |||
| 1107 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 1108 | const progressEvent = { | ||
| 1109 | actionId: "action-1", | ||
| 1110 | cwd: "/repo", | ||
| 1111 | action: "commit", | ||
| 1112 | kind: "phase_started", | ||
| 1113 | phase: "commit", | ||
| 1114 | label: "Committing...", | ||
| 1115 | } as const; | ||
| 1116 | |||
| 1117 | socket.serverMessage( | ||
| 1118 | JSON.stringify({ | ||
| 1119 | _tag: "Chunk", | ||
| 1120 | requestId: requestMessage.id, | ||
| 1121 | values: [progressEvent], | ||
| 1122 | }), | ||
| 1123 | ); | ||
| 1124 | socket.serverMessage( | ||
| 1125 | JSON.stringify({ | ||
| 1126 | _tag: "Exit", | ||
| 1127 | requestId: requestMessage.id, | ||
| 1128 | exit: { | ||
| 1129 | _tag: "Success", | ||
| 1130 | value: null, | ||
| 1131 | }, | ||
| 1132 | }), | ||
| 1133 | ); | ||
| 1134 | |||
| 1135 | await expect(requestPromise).resolves.toBeUndefined(); | ||
| 1136 | expect(listener).toHaveBeenCalledWith(progressEvent); | ||
| 1137 | expect( | ||
| 1138 | socket.sent.filter((message) => { | ||
| 1139 | const parsed = JSON.parse(message) as { _tag?: string; tag?: string }; | ||
| 1140 | return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction; | ||
| 1141 | }), | ||
| 1142 | ).toHaveLength(1); | ||
| 1143 | await transport.dispose(); | ||
| 1144 | }); | ||
| 1145 | |||
| 1146 | it("closes the client scope on the transport runtime before disposing the runtime", async () => { | ||
| 1147 | const callOrder: string[] = []; | ||
| 1148 | let resolveClose!: () => void; | ||
| 1149 | const closePromise = new Promise<void>((resolve) => { | ||
| 1150 | resolveClose = resolve; | ||
| 1151 | }); | ||
| 1152 | |||
| 1153 | const runtime = { | ||
| 1154 | runPromise: vi.fn(async () => { | ||
| 1155 | callOrder.push("close:start"); | ||
| 1156 | await closePromise; | ||
| 1157 | callOrder.push("close:done"); | ||
| 1158 | return undefined; | ||
| 1159 | }), | ||
| 1160 | dispose: vi.fn(async () => { | ||
| 1161 | callOrder.push("runtime:dispose"); | ||
| 1162 | }), | ||
| 1163 | }; | ||
| 1164 | const transport = { | ||
| 1165 | disposed: false, | ||
| 1166 | session: { | ||
| 1167 | clientScope: {} as never, | ||
| 1168 | runtime, | ||
| 1169 | }, | ||
| 1170 | closeSession: ( | ||
| 1171 | WsTransport.prototype as unknown as { | ||
| 1172 | closeSession: (session: { | ||
| 1173 | clientScope: unknown; | ||
| 1174 | runtime: { dispose: () => Promise<void>; runPromise: () => Promise<void> }; | ||
| 1175 | }) => Promise<void>; | ||
| 1176 | } | ||
| 1177 | ).closeSession, | ||
| 1178 | } as unknown as WsTransport; | ||
| 1179 | |||
| 1180 | void WsTransport.prototype.dispose.call(transport); | ||
| 1181 | |||
| 1182 | expect(runtime.runPromise).toHaveBeenCalledTimes(1); | ||
| 1183 | expect(runtime.dispose).not.toHaveBeenCalled(); | ||
| 1184 | expect((transport as unknown as { disposed: boolean }).disposed).toBe(true); | ||
| 1185 | |||
| 1186 | resolveClose(); | ||
| 1187 | |||
| 1188 | await waitFor(() => { | ||
| 1189 | expect(runtime.dispose).toHaveBeenCalledTimes(1); | ||
| 1190 | }); | ||
| 1191 | |||
| 1192 | expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); | ||
| 1193 | }); | ||
| 1194 | |||
| 1195 | it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => { | 370 | it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => { |
| 1196 | await configureClientTracing({ | 371 | await configureClientTracing({ |
| 1197 | exportIntervalMs: 10, | 372 | exportIntervalMs: 10, |
apps/web/src/rpc/wsTransport.ts+50-348| ... | @@ -1,361 +1,63 @@ | ... | @@ -1,361 +1,63 @@ |
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as ManagedRuntime from "effect/ManagedRuntime"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Scope from "effect/Scope"; | ||
| 9 | import * as Stream from "effect/Stream"; | ||
| 10 | import { RpcClient } from "effect/unstable/rpc"; | ||
| 11 | |||
| 12 | import { ClientTracingLive } from "../observability/clientTracing"; | ||
| 13 | import { clearAllTrackedRpcRequests } from "./requestLatencyState"; | ||
| 14 | import { | 1 | import { |
| 15 | createWsRpcProtocolLayer, | 2 | WsTransport as BaseWsTransport, |
| 16 | makeWsRpcProtocolClient, | ||
| 17 | type WsProtocolLifecycleHandlers, | 3 | type WsProtocolLifecycleHandlers, |
| 18 | type WsRpcProtocolClient, | ||
| 19 | type WsRpcProtocolSocketUrlProvider, | 4 | type WsRpcProtocolSocketUrlProvider, |
| 20 | } from "./protocol"; | 5 | type WsTransportOptions, |
| 21 | import { isTransportConnectionErrorMessage } from "./transportError"; | 6 | } from "@t3tools/client-runtime"; |
| 22 | 7 | import { createWsRpcProtocolLayer as createSharedWsRpcProtocolLayer } from "@t3tools/client-runtime"; | |
| 23 | interface SubscribeOptions { | ||
| 24 | readonly retryDelay?: Duration.Input; | ||
| 25 | readonly onResubscribe?: () => void; | ||
| 26 | readonly tag?: string; | ||
| 27 | } | ||
| 28 | |||
| 29 | interface RequestOptions { | ||
| 30 | readonly timeout?: Option.Option<Duration.Input>; | ||
| 31 | } | ||
| 32 | |||
| 33 | const DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS = Duration.millis(250); | ||
| 34 | const NOOP: () => void = () => undefined; | ||
| 35 | |||
| 36 | interface TransportSession { | ||
| 37 | readonly clientPromise: Promise<WsRpcProtocolClient>; | ||
| 38 | readonly clientScope: Scope.Closeable; | ||
| 39 | readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>; | ||
| 40 | } | ||
| 41 | 8 | ||
| 42 | interface StreamRequestStartInfo { | 9 | import { ClientTracingLive } from "../observability/clientTracing"; |
| 43 | readonly id: string; | 10 | import { |
| 44 | readonly tag: string; | 11 | acknowledgeRpcRequest, |
| 45 | readonly stream: boolean; | 12 | clearAllTrackedRpcRequests, |
| 46 | } | 13 | trackRpcRequestSent, |
| 47 | 14 | } from "./requestLatencyState"; | |
| 48 | function formatErrorMessage(error: unknown): string { | 15 | import { |
| 49 | if (error instanceof Error && error.message.trim().length > 0) { | 16 | recordWsConnectionAttempt, |
| 50 | return error.message; | 17 | recordWsConnectionClosed, |
| 51 | } | 18 | recordWsConnectionErrored, |
| 52 | return String(error); | 19 | recordWsConnectionOpened, |
| 20 | } from "./wsConnectionState"; | ||
| 21 | |||
| 22 | function createWsRpcProtocolLayer( | ||
| 23 | url: WsRpcProtocolSocketUrlProvider, | ||
| 24 | handlers?: WsProtocolLifecycleHandlers, | ||
| 25 | ) { | ||
| 26 | return createSharedWsRpcProtocolLayer(url, handlers, { | ||
| 27 | telemetryLifecycle: { | ||
| 28 | onAttempt: recordWsConnectionAttempt, | ||
| 29 | onOpen: recordWsConnectionOpened, | ||
| 30 | onError: (message) => { | ||
| 31 | clearAllTrackedRpcRequests(); | ||
| 32 | recordWsConnectionErrored(message); | ||
| 33 | }, | ||
| 34 | onClose: (details, context) => { | ||
| 35 | clearAllTrackedRpcRequests(); | ||
| 36 | if (context.intentional) { | ||
| 37 | return; | ||
| 38 | } | ||
| 39 | recordWsConnectionClosed(details); | ||
| 40 | }, | ||
| 41 | }, | ||
| 42 | requestTelemetry: { | ||
| 43 | onRequestSent: trackRpcRequestSent, | ||
| 44 | onRequestAcknowledged: acknowledgeRpcRequest, | ||
| 45 | onClearTrackedRequests: clearAllTrackedRpcRequests, | ||
| 46 | }, | ||
| 47 | }); | ||
| 53 | } | 48 | } |
| 54 | 49 | ||
| 55 | export class WsTransport { | 50 | const webWsTransportOptions = { |
| 56 | private readonly url: WsRpcProtocolSocketUrlProvider; | 51 | tracingLayer: ClientTracingLive, |
| 57 | private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; | 52 | createProtocolLayer: createWsRpcProtocolLayer, |
| 58 | private disposed = false; | 53 | onBeforeReconnect: () => clearAllTrackedRpcRequests(), |
| 59 | private hasReportedTransportDisconnect = false; | 54 | } satisfies WsTransportOptions; |
| 60 | private intentionalCloseDepth = 0; | ||
| 61 | private reconnectChain: Promise<void> = Promise.resolve(); | ||
| 62 | private nextSessionId = 0; | ||
| 63 | private activeSessionId = 0; | ||
| 64 | private session: TransportSession; | ||
| 65 | private lastHeartbeatPongAt = 0; | ||
| 66 | private readonly streamRequestStartListeners = new Set<(info: StreamRequestStartInfo) => void>(); | ||
| 67 | 55 | ||
| 56 | export class WsTransport extends BaseWsTransport { | ||
| 68 | constructor( | 57 | constructor( |
| 69 | url: WsRpcProtocolSocketUrlProvider, | 58 | url: WsRpcProtocolSocketUrlProvider, |
| 70 | lifecycleHandlers?: WsProtocolLifecycleHandlers, | 59 | lifecycleHandlers?: WsProtocolLifecycleHandlers, |
| 71 | ) { | 60 | ) { |
| 72 | this.url = url; | 61 | super(url, lifecycleHandlers, webWsTransportOptions); |
| 73 | this.lifecycleHandlers = lifecycleHandlers; | ||
| 74 | this.session = this.createSession(); | ||
| 75 | } | ||
| 76 | |||
| 77 | async request<TSuccess>( | ||
| 78 | execute: (client: WsRpcProtocolClient) => Effect.Effect<TSuccess, Error, never>, | ||
| 79 | _options?: RequestOptions, | ||
| 80 | ): Promise<TSuccess> { | ||
| 81 | if (this.disposed) { | ||
| 82 | throw new Error("Transport disposed"); | ||
| 83 | } | ||
| 84 | |||
| 85 | const session = this.session; | ||
| 86 | const client = await session.clientPromise; | ||
| 87 | return await session.runtime.runPromise(Effect.suspend(() => execute(client))); | ||
| 88 | } | ||
| 89 | |||
| 90 | async requestStream<TValue>( | ||
| 91 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | ||
| 92 | listener: (value: TValue) => void, | ||
| 93 | ): Promise<void> { | ||
| 94 | if (this.disposed) { | ||
| 95 | throw new Error("Transport disposed"); | ||
| 96 | } | ||
| 97 | |||
| 98 | const session = this.session; | ||
| 99 | const client = await session.clientPromise; | ||
| 100 | await session.runtime.runPromise( | ||
| 101 | Stream.runForEach(connect(client), (value) => | ||
| 102 | Effect.sync(() => { | ||
| 103 | try { | ||
| 104 | listener(value); | ||
| 105 | } catch { | ||
| 106 | // Swallow listener errors so the stream can finish cleanly. | ||
| 107 | } | ||
| 108 | }), | ||
| 109 | ), | ||
| 110 | ); | ||
| 111 | } | ||
| 112 | |||
| 113 | subscribe<TValue>( | ||
| 114 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | ||
| 115 | listener: (value: TValue) => void, | ||
| 116 | options?: SubscribeOptions, | ||
| 117 | ): () => void { | ||
| 118 | if (this.disposed) { | ||
| 119 | return () => undefined; | ||
| 120 | } | ||
| 121 | |||
| 122 | let active = true; | ||
| 123 | let hasReceivedValue = false; | ||
| 124 | const retryDelayMs = Duration.toMillis( | ||
| 125 | Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS), | ||
| 126 | ); | ||
| 127 | let cancelCurrentStream: () => void = NOOP; | ||
| 128 | |||
| 129 | void (async () => { | ||
| 130 | for (;;) { | ||
| 131 | if (!active || this.disposed) { | ||
| 132 | return; | ||
| 133 | } | ||
| 134 | |||
| 135 | const session = this.session; | ||
| 136 | try { | ||
| 137 | const runningStream = this.runStreamOnSession( | ||
| 138 | session, | ||
| 139 | connect, | ||
| 140 | listener, | ||
| 141 | { | ||
| 142 | ...(options?.tag === undefined ? {} : { tag: options.tag }), | ||
| 143 | ...(hasReceivedValue | ||
| 144 | ? { | ||
| 145 | onStarted: () => { | ||
| 146 | try { | ||
| 147 | options?.onResubscribe?.(); | ||
| 148 | } catch { | ||
| 149 | // Swallow reconnect hook errors so the stream can recover. | ||
| 150 | } | ||
| 151 | }, | ||
| 152 | } | ||
| 153 | : {}), | ||
| 154 | }, | ||
| 155 | () => active, | ||
| 156 | () => { | ||
| 157 | this.hasReportedTransportDisconnect = false; | ||
| 158 | hasReceivedValue = true; | ||
| 159 | }, | ||
| 160 | ); | ||
| 161 | cancelCurrentStream = runningStream.cancel; | ||
| 162 | await runningStream.completed; | ||
| 163 | cancelCurrentStream = NOOP; | ||
| 164 | } catch (error) { | ||
| 165 | cancelCurrentStream = NOOP; | ||
| 166 | if (!active || this.disposed) { | ||
| 167 | return; | ||
| 168 | } | ||
| 169 | |||
| 170 | if (session !== this.session) { | ||
| 171 | continue; | ||
| 172 | } | ||
| 173 | |||
| 174 | const formattedError = formatErrorMessage(error); | ||
| 175 | if (!isTransportConnectionErrorMessage(formattedError)) { | ||
| 176 | console.warn("WebSocket RPC subscription failed", { | ||
| 177 | error: formattedError, | ||
| 178 | }); | ||
| 179 | return; | ||
| 180 | } | ||
| 181 | |||
| 182 | if (!this.hasReportedTransportDisconnect) { | ||
| 183 | console.warn("WebSocket RPC subscription disconnected", { | ||
| 184 | error: formattedError, | ||
| 185 | }); | ||
| 186 | } | ||
| 187 | this.hasReportedTransportDisconnect = true; | ||
| 188 | await sleep(retryDelayMs); | ||
| 189 | } | ||
| 190 | } | ||
| 191 | })(); | ||
| 192 | |||
| 193 | return () => { | ||
| 194 | active = false; | ||
| 195 | cancelCurrentStream(); | ||
| 196 | }; | ||
| 197 | } | ||
| 198 | |||
| 199 | async reconnect() { | ||
| 200 | if (this.disposed) { | ||
| 201 | throw new Error("Transport disposed"); | ||
| 202 | } | ||
| 203 | |||
| 204 | const reconnectOperation = this.reconnectChain.then(async () => { | ||
| 205 | if (this.disposed) { | ||
| 206 | throw new Error("Transport disposed"); | ||
| 207 | } | ||
| 208 | |||
| 209 | clearAllTrackedRpcRequests(); | ||
| 210 | this.lastHeartbeatPongAt = 0; | ||
| 211 | const previousSession = this.session; | ||
| 212 | this.session = this.createSession(); | ||
| 213 | await this.closeSession(previousSession); | ||
| 214 | }); | ||
| 215 | |||
| 216 | this.reconnectChain = reconnectOperation.catch(() => undefined); | ||
| 217 | await reconnectOperation; | ||
| 218 | } | ||
| 219 | |||
| 220 | isHeartbeatFresh(maxAgeMs = 15_000): boolean { | ||
| 221 | return this.lastHeartbeatPongAt > 0 && Date.now() - this.lastHeartbeatPongAt <= maxAgeMs; | ||
| 222 | } | ||
| 223 | |||
| 224 | async dispose() { | ||
| 225 | if (this.disposed) { | ||
| 226 | return; | ||
| 227 | } | ||
| 228 | this.disposed = true; | ||
| 229 | await this.closeSession(this.session); | ||
| 230 | } | ||
| 231 | |||
| 232 | private closeSession(session: TransportSession) { | ||
| 233 | this.intentionalCloseDepth += 1; | ||
| 234 | return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { | ||
| 235 | this.intentionalCloseDepth -= 1; | ||
| 236 | session.runtime.dispose(); | ||
| 237 | }); | ||
| 238 | } | 62 | } |
| 239 | |||
| 240 | private createSession(): TransportSession { | ||
| 241 | const sessionId = this.nextSessionId + 1; | ||
| 242 | this.nextSessionId = sessionId; | ||
| 243 | this.activeSessionId = sessionId; | ||
| 244 | const runtime = ManagedRuntime.make( | ||
| 245 | Layer.mergeAll( | ||
| 246 | createWsRpcProtocolLayer(this.url, { | ||
| 247 | ...this.lifecycleHandlers, | ||
| 248 | isActive: () => !this.disposed && this.activeSessionId === sessionId, | ||
| 249 | isCloseIntentional: () => | ||
| 250 | this.disposed || | ||
| 251 | this.intentionalCloseDepth > 0 || | ||
| 252 | this.lifecycleHandlers?.isCloseIntentional?.() === true, | ||
| 253 | onHeartbeatPong: () => { | ||
| 254 | this.lastHeartbeatPongAt = Date.now(); | ||
| 255 | this.lifecycleHandlers?.onHeartbeatPong?.(); | ||
| 256 | }, | ||
| 257 | onRequestStart: (info) => { | ||
| 258 | this.lifecycleHandlers?.onRequestStart?.(info); | ||
| 259 | if (!info.stream) { | ||
| 260 | return; | ||
| 261 | } | ||
| 262 | for (const listener of this.streamRequestStartListeners) { | ||
| 263 | listener(info); | ||
| 264 | } | ||
| 265 | }, | ||
| 266 | }), | ||
| 267 | ClientTracingLive, | ||
| 268 | ), | ||
| 269 | ); | ||
| 270 | const clientScope = runtime.runSync(Scope.make()); | ||
| 271 | return { | ||
| 272 | runtime, | ||
| 273 | clientScope, | ||
| 274 | clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)), | ||
| 275 | }; | ||
| 276 | } | ||
| 277 | |||
| 278 | private runStreamOnSession<TValue>( | ||
| 279 | session: TransportSession, | ||
| 280 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | ||
| 281 | listener: (value: TValue) => void, | ||
| 282 | requestStart: { | ||
| 283 | readonly tag?: string; | ||
| 284 | readonly onStarted?: () => void; | ||
| 285 | }, | ||
| 286 | isActive: () => boolean, | ||
| 287 | markValueReceived: () => void, | ||
| 288 | ): { | ||
| 289 | readonly cancel: () => void; | ||
| 290 | readonly completed: Promise<void>; | ||
| 291 | } { | ||
| 292 | let resolveCompleted!: () => void; | ||
| 293 | let rejectCompleted!: (error: unknown) => void; | ||
| 294 | const completed = new Promise<void>((resolve, reject) => { | ||
| 295 | resolveCompleted = resolve; | ||
| 296 | rejectCompleted = reject; | ||
| 297 | }); | ||
| 298 | let requestStartListener: ((info: StreamRequestStartInfo) => void) | null = null; | ||
| 299 | if (requestStart.onStarted) { | ||
| 300 | requestStartListener = (info) => { | ||
| 301 | if (!isActive() || !info.stream) { | ||
| 302 | return; | ||
| 303 | } | ||
| 304 | if (requestStart.tag !== undefined && info.tag !== requestStart.tag) { | ||
| 305 | return; | ||
| 306 | } | ||
| 307 | requestStart.onStarted?.(); | ||
| 308 | if (requestStartListener) { | ||
| 309 | this.streamRequestStartListeners.delete(requestStartListener); | ||
| 310 | requestStartListener = null; | ||
| 311 | } | ||
| 312 | }; | ||
| 313 | this.streamRequestStartListeners.add(requestStartListener); | ||
| 314 | } | ||
| 315 | const cancel = session.runtime.runCallback( | ||
| 316 | Effect.promise(() => session.clientPromise).pipe( | ||
| 317 | Effect.flatMap((client) => | ||
| 318 | Stream.runForEach(connect(client), (value) => | ||
| 319 | Effect.sync(() => { | ||
| 320 | if (!isActive()) { | ||
| 321 | return; | ||
| 322 | } | ||
| 323 | |||
| 324 | markValueReceived(); | ||
| 325 | try { | ||
| 326 | listener(value); | ||
| 327 | } catch { | ||
| 328 | // Swallow listener errors so the stream stays live. | ||
| 329 | } | ||
| 330 | }), | ||
| 331 | ), | ||
| 332 | ), | ||
| 333 | ), | ||
| 334 | { | ||
| 335 | onExit: (exit) => { | ||
| 336 | if (requestStartListener) { | ||
| 337 | this.streamRequestStartListeners.delete(requestStartListener); | ||
| 338 | requestStartListener = null; | ||
| 339 | } | ||
| 340 | if (Exit.isSuccess(exit)) { | ||
| 341 | resolveCompleted(); | ||
| 342 | return; | ||
| 343 | } | ||
| 344 | |||
| 345 | rejectCompleted(Cause.squash(exit.cause)); | ||
| 346 | }, | ||
| 347 | }, | ||
| 348 | ); | ||
| 349 | |||
| 350 | return { | ||
| 351 | cancel, | ||
| 352 | completed, | ||
| 353 | }; | ||
| 354 | } | ||
| 355 | } | ||
| 356 | |||
| 357 | function sleep(ms: number): Promise<void> { | ||
| 358 | return new Promise((resolve) => { | ||
| 359 | setTimeout(resolve, ms); | ||
| 360 | }); | ||
| 361 | } | 63 | } |
apps/web/src/store.ts+12| ... | @@ -43,6 +43,12 @@ export interface EnvironmentState { | ... | @@ -43,6 +43,12 @@ export interface EnvironmentState { |
| 43 | projectIds: ProjectId[]; | 43 | projectIds: ProjectId[]; |
| 44 | projectById: Record<ProjectId, Project>; | 44 | projectById: Record<ProjectId, Project>; |
| 45 | 45 | ||
| 46 | // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): | ||
| 47 | // Web still stores shell snapshots and thread details in this denormalized | ||
| 48 | // Zustand shape. Mobile uses createShellSnapshotManager and | ||
| 49 | // createThreadDetailManager from @t3tools/client-runtime. New shared behavior | ||
| 50 | // belongs in those managers/reducers, with a web adapter layered on top. | ||
| 51 | // | ||
| 46 | // --------------------------------------------------------------------------- | 52 | // --------------------------------------------------------------------------- |
| 47 | // Thread bookkeeping — written by BOTH shell stream and detail stream. | 53 | // Thread bookkeeping — written by BOTH shell stream and detail stream. |
| 48 | // Both streams ensure the thread is registered here; the bookkeeping is | 54 | // Both streams ensure the thread is registered here; the bookkeeping is |
| ... | @@ -1120,6 +1126,9 @@ export function syncServerShellSnapshot( | ... | @@ -1120,6 +1126,9 @@ export function syncServerShellSnapshot( |
| 1120 | snapshot: OrchestrationShellSnapshot, | 1126 | snapshot: OrchestrationShellSnapshot, |
| 1121 | environmentId: EnvironmentId, | 1127 | environmentId: EnvironmentId, |
| 1122 | ): AppState { | 1128 | ): AppState { |
| 1129 | // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): | ||
| 1130 | // Keep web-specific projection here only until the store can consume | ||
| 1131 | // createShellSnapshotManager or a shared adapter over its reducer. | ||
| 1123 | return commitEnvironmentState( | 1132 | return commitEnvironmentState( |
| 1124 | state, | 1133 | state, |
| 1125 | environmentId, | 1134 | environmentId, |
| ... | @@ -1136,6 +1145,9 @@ export function syncServerThreadDetail( | ... | @@ -1136,6 +1145,9 @@ export function syncServerThreadDetail( |
| 1136 | thread: OrchestrationThread, | 1145 | thread: OrchestrationThread, |
| 1137 | environmentId: EnvironmentId, | 1146 | environmentId: EnvironmentId, |
| 1138 | ): AppState { | 1147 | ): AppState { |
| 1148 | // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): | ||
| 1149 | // Keep web-specific projection here only until the store can consume | ||
| 1150 | // createThreadDetailManager or a shared adapter over its reducer. | ||
| 1139 | const environmentState = getStoredEnvironmentState(state, environmentId); | 1151 | const environmentState = getStoredEnvironmentState(state, environmentId); |
| 1140 | const previousThread = getThreadFromEnvironmentState(environmentState, thread.id); | 1152 | const previousThread = getThreadFromEnvironmentState(environmentState, thread.id); |
| 1141 | return commitEnvironmentState( | 1153 | return commitEnvironmentState( |
apps/web/src/terminalActivity.test.ts deleted-72| ... | @@ -1,72 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import type { TerminalEvent, TerminalSessionSnapshot } from "@t3tools/contracts"; | ||
| 4 | import { terminalRunningSubprocessFromEvent } from "./terminalActivity"; | ||
| 5 | |||
| 6 | const snapshot: TerminalSessionSnapshot = { | ||
| 7 | threadId: "thread-1", | ||
| 8 | terminalId: "default", | ||
| 9 | cwd: "/tmp", | ||
| 10 | worktreePath: null, | ||
| 11 | status: "running", | ||
| 12 | pid: 1234, | ||
| 13 | history: "", | ||
| 14 | exitCode: null, | ||
| 15 | exitSignal: null, | ||
| 16 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 17 | }; | ||
| 18 | |||
| 19 | function eventBase() { | ||
| 20 | return { | ||
| 21 | threadId: "thread-1", | ||
| 22 | terminalId: "default", | ||
| 23 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 24 | }; | ||
| 25 | } | ||
| 26 | |||
| 27 | describe("terminalRunningSubprocessFromEvent", () => { | ||
| 28 | it("returns the subprocess flag for terminal activity events", () => { | ||
| 29 | const active = terminalRunningSubprocessFromEvent({ | ||
| 30 | ...eventBase(), | ||
| 31 | type: "activity", | ||
| 32 | hasRunningSubprocess: true, | ||
| 33 | }); | ||
| 34 | const idle = terminalRunningSubprocessFromEvent({ | ||
| 35 | ...eventBase(), | ||
| 36 | type: "activity", | ||
| 37 | hasRunningSubprocess: false, | ||
| 38 | }); | ||
| 39 | |||
| 40 | expect(active).toBe(true); | ||
| 41 | expect(idle).toBe(false); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("clears running state when a terminal session starts/restarts/exits", () => { | ||
| 45 | const events: TerminalEvent[] = [ | ||
| 46 | { ...eventBase(), type: "started", snapshot }, | ||
| 47 | { ...eventBase(), type: "restarted", snapshot }, | ||
| 48 | { ...eventBase(), type: "exited", exitCode: 0, exitSignal: null }, | ||
| 49 | ]; | ||
| 50 | |||
| 51 | for (const event of events) { | ||
| 52 | expect(terminalRunningSubprocessFromEvent(event)).toBe(false); | ||
| 53 | } | ||
| 54 | }); | ||
| 55 | |||
| 56 | it("ignores non-activity terminal events", () => { | ||
| 57 | expect( | ||
| 58 | terminalRunningSubprocessFromEvent({ | ||
| 59 | ...eventBase(), | ||
| 60 | type: "output", | ||
| 61 | data: "hello", | ||
| 62 | }), | ||
| 63 | ).toBeNull(); | ||
| 64 | expect( | ||
| 65 | terminalRunningSubprocessFromEvent({ | ||
| 66 | ...eventBase(), | ||
| 67 | type: "error", | ||
| 68 | message: "oops", | ||
| 69 | }), | ||
| 70 | ).toBeNull(); | ||
| 71 | }); | ||
| 72 | }); | ||
apps/web/src/terminalActivity.ts deleted-14| ... | @@ -1,14 +0,0 @@ | ||
| 1 | import type { TerminalEvent } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export function terminalRunningSubprocessFromEvent(event: TerminalEvent): boolean | null { | ||
| 4 | switch (event.type) { | ||
| 5 | case "activity": | ||
| 6 | return event.hasRunningSubprocess; | ||
| 7 | case "started": | ||
| 8 | case "restarted": | ||
| 9 | case "exited": | ||
| 10 | return false; | ||
| 11 | default: | ||
| 12 | return null; | ||
| 13 | } | ||
| 14 | } | ||
apps/web/src/terminalSessionState.ts created+77| ... | @@ -0,0 +1,77 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, | ||
| 4 | EMPTY_TERMINAL_ID_LIST_ATOM, | ||
| 5 | EMPTY_TERMINAL_SESSION_ATOM, | ||
| 6 | createTerminalSessionManager, | ||
| 7 | getKnownTerminalSessionTarget, | ||
| 8 | getKnownTerminalSessionListFilter, | ||
| 9 | knownTerminalSessionsAtom, | ||
| 10 | runningTerminalIdsAtom, | ||
| 11 | terminalSessionStateAtom, | ||
| 12 | type KnownTerminalSession, | ||
| 13 | type TerminalSessionTarget, | ||
| 14 | type TerminalSessionState, | ||
| 15 | } from "@t3tools/client-runtime"; | ||
| 16 | import type { | ||
| 17 | EnvironmentId, | ||
| 18 | TerminalAttachInput, | ||
| 19 | TerminalSessionSnapshot, | ||
| 20 | ThreadId, | ||
| 21 | } from "@t3tools/contracts"; | ||
| 22 | |||
| 23 | import { appAtomRegistry } from "./rpc/atomRegistry"; | ||
| 24 | |||
| 25 | export const terminalSessionManager = createTerminalSessionManager({ | ||
| 26 | getRegistry: () => appAtomRegistry, | ||
| 27 | }); | ||
| 28 | |||
| 29 | export function subscribeTerminalMetadata(input: { | ||
| 30 | readonly environmentId: EnvironmentId; | ||
| 31 | readonly client: Parameters<typeof terminalSessionManager.subscribeMetadata>[0]["client"]; | ||
| 32 | }) { | ||
| 33 | return terminalSessionManager.subscribeMetadata(input); | ||
| 34 | } | ||
| 35 | |||
| 36 | export function attachTerminalSession(input: { | ||
| 37 | readonly environmentId: EnvironmentId; | ||
| 38 | readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"]; | ||
| 39 | readonly terminal: TerminalAttachInput; | ||
| 40 | readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; | ||
| 41 | readonly onEvent?: Parameters<typeof terminalSessionManager.attach>[0]["onEvent"]; | ||
| 42 | }) { | ||
| 43 | return terminalSessionManager.attach({ | ||
| 44 | environmentId: input.environmentId, | ||
| 45 | client: input.client, | ||
| 46 | terminal: input.terminal, | ||
| 47 | ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), | ||
| 48 | ...(input.onEvent ? { onEvent: input.onEvent } : {}), | ||
| 49 | }); | ||
| 50 | } | ||
| 51 | |||
| 52 | export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState { | ||
| 53 | const target = getKnownTerminalSessionTarget(input); | ||
| 54 | return useAtomValue( | ||
| 55 | target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM, | ||
| 56 | ); | ||
| 57 | } | ||
| 58 | |||
| 59 | export function useKnownTerminalSessions(input: { | ||
| 60 | readonly environmentId: EnvironmentId | null; | ||
| 61 | readonly threadId: ThreadId | null; | ||
| 62 | }): ReadonlyArray<KnownTerminalSession> { | ||
| 63 | const filter = getKnownTerminalSessionListFilter(input); | ||
| 64 | return useAtomValue( | ||
| 65 | filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | |||
| 69 | export function useThreadRunningTerminalIds(input: { | ||
| 70 | readonly environmentId: EnvironmentId | null; | ||
| 71 | readonly threadId: ThreadId | null; | ||
| 72 | }): ReadonlyArray<string> { | ||
| 73 | const filter = getKnownTerminalSessionListFilter(input); | ||
| 74 | return useAtomValue( | ||
| 75 | filter !== null ? runningTerminalIdsAtom(filter) : EMPTY_TERMINAL_ID_LIST_ATOM, | ||
| 76 | ); | ||
| 77 | } | ||
apps/web/src/terminalStateStore.test.ts deleted-396| ... | @@ -1,396 +0,0 @@ | ||
| 1 | import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; | ||
| 2 | import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; | ||
| 3 | import { beforeEach, describe, expect, it } from "vitest"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | migratePersistedTerminalStateStoreState, | ||
| 7 | selectTerminalEventEntries, | ||
| 8 | selectThreadTerminalState, | ||
| 9 | useTerminalStateStore, | ||
| 10 | } from "./terminalStateStore"; | ||
| 11 | |||
| 12 | const THREAD_ID = ThreadId.make("thread-1"); | ||
| 13 | const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID); | ||
| 14 | const OTHER_THREAD_REF = scopeThreadRef("environment-b" as never, THREAD_ID); | ||
| 15 | |||
| 16 | function makeTerminalEvent( | ||
| 17 | type: TerminalEvent["type"], | ||
| 18 | overrides: Partial<TerminalEvent> = {}, | ||
| 19 | ): TerminalEvent { | ||
| 20 | const base = { | ||
| 21 | threadId: THREAD_ID, | ||
| 22 | terminalId: "default", | ||
| 23 | createdAt: "2026-04-02T20:00:00.000Z", | ||
| 24 | }; | ||
| 25 | |||
| 26 | switch (type) { | ||
| 27 | case "output": | ||
| 28 | return { ...base, type, data: "hello\n", ...overrides } as TerminalEvent; | ||
| 29 | case "activity": | ||
| 30 | return { ...base, type, hasRunningSubprocess: true, ...overrides } as TerminalEvent; | ||
| 31 | case "error": | ||
| 32 | return { ...base, type, message: "boom", ...overrides } as TerminalEvent; | ||
| 33 | case "cleared": | ||
| 34 | return { ...base, type, ...overrides } as TerminalEvent; | ||
| 35 | case "exited": | ||
| 36 | return { ...base, type, exitCode: 0, exitSignal: null, ...overrides } as TerminalEvent; | ||
| 37 | case "started": | ||
| 38 | case "restarted": | ||
| 39 | return { | ||
| 40 | ...base, | ||
| 41 | type, | ||
| 42 | snapshot: { | ||
| 43 | threadId: THREAD_ID, | ||
| 44 | terminalId: "default", | ||
| 45 | cwd: "/tmp/workspace", | ||
| 46 | worktreePath: null, | ||
| 47 | status: "running", | ||
| 48 | pid: 123, | ||
| 49 | history: "", | ||
| 50 | exitCode: null, | ||
| 51 | exitSignal: null, | ||
| 52 | updatedAt: "2026-04-02T20:00:00.000Z", | ||
| 53 | }, | ||
| 54 | ...overrides, | ||
| 55 | } as TerminalEvent; | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | describe("terminalStateStore actions", () => { | ||
| 60 | beforeEach(() => { | ||
| 61 | useTerminalStateStore.persist.clearStorage(); | ||
| 62 | useTerminalStateStore.setState({ | ||
| 63 | terminalStateByThreadKey: {}, | ||
| 64 | terminalLaunchContextByThreadKey: {}, | ||
| 65 | terminalEventEntriesByKey: {}, | ||
| 66 | nextTerminalEventId: 1, | ||
| 67 | }); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it("returns a closed default terminal state for unknown threads", () => { | ||
| 71 | const terminalState = selectThreadTerminalState( | ||
| 72 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 73 | THREAD_REF, | ||
| 74 | ); | ||
| 75 | expect(terminalState).toEqual({ | ||
| 76 | terminalOpen: false, | ||
| 77 | terminalHeight: 280, | ||
| 78 | terminalIds: ["default"], | ||
| 79 | runningTerminalIds: [], | ||
| 80 | activeTerminalId: "default", | ||
| 81 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | ||
| 82 | activeTerminalGroupId: "group-default", | ||
| 83 | }); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("opens and splits terminals into the active group", () => { | ||
| 87 | const store = useTerminalStateStore.getState(); | ||
| 88 | store.setTerminalOpen(THREAD_REF, true); | ||
| 89 | store.splitTerminal(THREAD_REF, "terminal-2"); | ||
| 90 | |||
| 91 | const terminalState = selectThreadTerminalState( | ||
| 92 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 93 | THREAD_REF, | ||
| 94 | ); | ||
| 95 | expect(terminalState.terminalOpen).toBe(true); | ||
| 96 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); | ||
| 97 | expect(terminalState.activeTerminalId).toBe("terminal-2"); | ||
| 98 | expect(terminalState.terminalGroups).toEqual([ | ||
| 99 | { id: "group-default", terminalIds: ["default", "terminal-2"] }, | ||
| 100 | ]); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("caps splits at four terminals per group", () => { | ||
| 104 | const store = useTerminalStateStore.getState(); | ||
| 105 | store.splitTerminal(THREAD_REF, "terminal-2"); | ||
| 106 | store.splitTerminal(THREAD_REF, "terminal-3"); | ||
| 107 | store.splitTerminal(THREAD_REF, "terminal-4"); | ||
| 108 | store.splitTerminal(THREAD_REF, "terminal-5"); | ||
| 109 | |||
| 110 | const terminalState = selectThreadTerminalState( | ||
| 111 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 112 | THREAD_REF, | ||
| 113 | ); | ||
| 114 | expect(terminalState.terminalIds).toEqual([ | ||
| 115 | "default", | ||
| 116 | "terminal-2", | ||
| 117 | "terminal-3", | ||
| 118 | "terminal-4", | ||
| 119 | ]); | ||
| 120 | expect(terminalState.terminalGroups).toEqual([ | ||
| 121 | { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] }, | ||
| 122 | ]); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("creates new terminals in a separate group", () => { | ||
| 126 | useTerminalStateStore.getState().newTerminal(THREAD_REF, "terminal-2"); | ||
| 127 | |||
| 128 | const terminalState = selectThreadTerminalState( | ||
| 129 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 130 | THREAD_REF, | ||
| 131 | ); | ||
| 132 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); | ||
| 133 | expect(terminalState.activeTerminalId).toBe("terminal-2"); | ||
| 134 | expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2"); | ||
| 135 | expect(terminalState.terminalGroups).toEqual([ | ||
| 136 | { id: "group-default", terminalIds: ["default"] }, | ||
| 137 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, | ||
| 138 | ]); | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("ensures unknown server terminals are registered, opened, and activated", () => { | ||
| 142 | const store = useTerminalStateStore.getState(); | ||
| 143 | store.ensureTerminal(THREAD_REF, "setup-setup", { open: true, active: true }); | ||
| 144 | |||
| 145 | const terminalState = selectThreadTerminalState( | ||
| 146 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 147 | THREAD_REF, | ||
| 148 | ); | ||
| 149 | expect(terminalState.terminalOpen).toBe(true); | ||
| 150 | expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]); | ||
| 151 | expect(terminalState.activeTerminalId).toBe("setup-setup"); | ||
| 152 | expect(terminalState.terminalGroups).toEqual([ | ||
| 153 | { id: "group-default", terminalIds: ["default"] }, | ||
| 154 | { id: "group-setup-setup", terminalIds: ["setup-setup"] }, | ||
| 155 | ]); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it("keeps state isolated per environment when raw thread ids collide", () => { | ||
| 159 | const store = useTerminalStateStore.getState(); | ||
| 160 | store.setTerminalOpen(THREAD_REF, true); | ||
| 161 | store.newTerminal(OTHER_THREAD_REF, "env-b-terminal"); | ||
| 162 | |||
| 163 | expect( | ||
| 164 | selectThreadTerminalState( | ||
| 165 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 166 | THREAD_REF, | ||
| 167 | ).terminalOpen, | ||
| 168 | ).toBe(true); | ||
| 169 | expect( | ||
| 170 | selectThreadTerminalState( | ||
| 171 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 172 | OTHER_THREAD_REF, | ||
| 173 | ).terminalIds, | ||
| 174 | ).toEqual(["default", "env-b-terminal"]); | ||
| 175 | }); | ||
| 176 | |||
| 177 | it("migrates v1 persisted terminal state using the stored version", () => { | ||
| 178 | const migrated = migratePersistedTerminalStateStoreState( | ||
| 179 | { | ||
| 180 | terminalStateByThreadKey: { | ||
| 181 | [scopedThreadKey(THREAD_REF)]: { | ||
| 182 | terminalOpen: true, | ||
| 183 | terminalHeight: 320, | ||
| 184 | terminalIds: ["default"], | ||
| 185 | runningTerminalIds: [], | ||
| 186 | activeTerminalId: "default", | ||
| 187 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | ||
| 188 | activeTerminalGroupId: "group-default", | ||
| 189 | }, | ||
| 190 | "legacy-thread-id": { | ||
| 191 | terminalOpen: true, | ||
| 192 | terminalHeight: 320, | ||
| 193 | terminalIds: ["default"], | ||
| 194 | runningTerminalIds: [], | ||
| 195 | activeTerminalId: "default", | ||
| 196 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | ||
| 197 | activeTerminalGroupId: "group-default", | ||
| 198 | }, | ||
| 199 | }, | ||
| 200 | }, | ||
| 201 | 1, | ||
| 202 | ); | ||
| 203 | |||
| 204 | expect(migrated).toEqual({ | ||
| 205 | terminalStateByThreadKey: { | ||
| 206 | [scopedThreadKey(THREAD_REF)]: { | ||
| 207 | terminalOpen: true, | ||
| 208 | terminalHeight: 320, | ||
| 209 | terminalIds: ["default"], | ||
| 210 | runningTerminalIds: [], | ||
| 211 | activeTerminalId: "default", | ||
| 212 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | ||
| 213 | activeTerminalGroupId: "group-default", | ||
| 214 | }, | ||
| 215 | }, | ||
| 216 | }); | ||
| 217 | }); | ||
| 218 | |||
| 219 | it("tracks and clears terminal subprocess activity", () => { | ||
| 220 | const store = useTerminalStateStore.getState(); | ||
| 221 | store.splitTerminal(THREAD_REF, "terminal-2"); | ||
| 222 | store.setTerminalActivity(THREAD_REF, "terminal-2", true); | ||
| 223 | expect( | ||
| 224 | selectThreadTerminalState( | ||
| 225 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 226 | THREAD_REF, | ||
| 227 | ).runningTerminalIds, | ||
| 228 | ).toEqual(["terminal-2"]); | ||
| 229 | |||
| 230 | store.setTerminalActivity(THREAD_REF, "terminal-2", false); | ||
| 231 | expect( | ||
| 232 | selectThreadTerminalState( | ||
| 233 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 234 | THREAD_REF, | ||
| 235 | ).runningTerminalIds, | ||
| 236 | ).toEqual([]); | ||
| 237 | }); | ||
| 238 | |||
| 239 | it("resets to default and clears persisted entry when closing the last terminal", () => { | ||
| 240 | const store = useTerminalStateStore.getState(); | ||
| 241 | store.closeTerminal(THREAD_REF, "default"); | ||
| 242 | |||
| 243 | expect( | ||
| 244 | useTerminalStateStore.getState().terminalStateByThreadKey[scopedThreadKey(THREAD_REF)], | ||
| 245 | ).toBeUndefined(); | ||
| 246 | expect( | ||
| 247 | selectThreadTerminalState( | ||
| 248 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 249 | THREAD_REF, | ||
| 250 | ).terminalIds, | ||
| 251 | ).toEqual(["default"]); | ||
| 252 | }); | ||
| 253 | |||
| 254 | it("keeps a valid active terminal after closing an active split terminal", () => { | ||
| 255 | const store = useTerminalStateStore.getState(); | ||
| 256 | store.splitTerminal(THREAD_REF, "terminal-2"); | ||
| 257 | store.splitTerminal(THREAD_REF, "terminal-3"); | ||
| 258 | store.closeTerminal(THREAD_REF, "terminal-3"); | ||
| 259 | |||
| 260 | const terminalState = selectThreadTerminalState( | ||
| 261 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 262 | THREAD_REF, | ||
| 263 | ); | ||
| 264 | expect(terminalState.activeTerminalId).toBe("terminal-2"); | ||
| 265 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); | ||
| 266 | expect(terminalState.terminalGroups).toEqual([ | ||
| 267 | { id: "group-default", terminalIds: ["default", "terminal-2"] }, | ||
| 268 | ]); | ||
| 269 | }); | ||
| 270 | |||
| 271 | it("buffers terminal events outside persisted terminal UI state", () => { | ||
| 272 | const store = useTerminalStateStore.getState(); | ||
| 273 | store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output")); | ||
| 274 | store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("activity")); | ||
| 275 | |||
| 276 | const entries = selectTerminalEventEntries( | ||
| 277 | useTerminalStateStore.getState().terminalEventEntriesByKey, | ||
| 278 | THREAD_REF, | ||
| 279 | "default", | ||
| 280 | ); | ||
| 281 | |||
| 282 | expect(entries).toHaveLength(2); | ||
| 283 | expect(entries.map((entry) => entry.id)).toEqual([1, 2]); | ||
| 284 | expect(entries.map((entry) => entry.event.type)).toEqual(["output", "activity"]); | ||
| 285 | }); | ||
| 286 | |||
| 287 | it("applies started terminal events to terminal state, launch context, and event buffer", () => { | ||
| 288 | const store = useTerminalStateStore.getState(); | ||
| 289 | store.applyTerminalEvent( | ||
| 290 | THREAD_REF, | ||
| 291 | makeTerminalEvent("started", { | ||
| 292 | terminalId: "setup-bootstrap", | ||
| 293 | snapshot: { | ||
| 294 | threadId: THREAD_ID, | ||
| 295 | terminalId: "setup-bootstrap", | ||
| 296 | cwd: "/tmp/worktree", | ||
| 297 | worktreePath: "/tmp/worktree", | ||
| 298 | status: "running", | ||
| 299 | pid: 123, | ||
| 300 | history: "", | ||
| 301 | exitCode: null, | ||
| 302 | exitSignal: null, | ||
| 303 | updatedAt: "2026-04-02T20:00:00.000Z", | ||
| 304 | }, | ||
| 305 | }), | ||
| 306 | ); | ||
| 307 | |||
| 308 | const terminalState = selectThreadTerminalState( | ||
| 309 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 310 | THREAD_REF, | ||
| 311 | ); | ||
| 312 | const entries = selectTerminalEventEntries( | ||
| 313 | useTerminalStateStore.getState().terminalEventEntriesByKey, | ||
| 314 | THREAD_REF, | ||
| 315 | "setup-bootstrap", | ||
| 316 | ); | ||
| 317 | |||
| 318 | expect(terminalState.terminalOpen).toBe(true); | ||
| 319 | expect(terminalState.activeTerminalId).toBe("setup-bootstrap"); | ||
| 320 | expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]); | ||
| 321 | expect( | ||
| 322 | useTerminalStateStore.getState().terminalLaunchContextByThreadKey[ | ||
| 323 | scopedThreadKey(THREAD_REF) | ||
| 324 | ], | ||
| 325 | ).toEqual({ | ||
| 326 | cwd: "/tmp/worktree", | ||
| 327 | worktreePath: "/tmp/worktree", | ||
| 328 | }); | ||
| 329 | expect(entries).toHaveLength(1); | ||
| 330 | expect(entries[0]?.event.type).toBe("started"); | ||
| 331 | }); | ||
| 332 | |||
| 333 | it("applies activity and exited terminal events to subprocess state while buffering events", () => { | ||
| 334 | const store = useTerminalStateStore.getState(); | ||
| 335 | store.ensureTerminal(THREAD_REF, "terminal-2", { open: true, active: true }); | ||
| 336 | |||
| 337 | store.applyTerminalEvent( | ||
| 338 | THREAD_REF, | ||
| 339 | makeTerminalEvent("activity", { | ||
| 340 | terminalId: "terminal-2", | ||
| 341 | hasRunningSubprocess: true, | ||
| 342 | }), | ||
| 343 | ); | ||
| 344 | expect( | ||
| 345 | selectThreadTerminalState( | ||
| 346 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 347 | THREAD_REF, | ||
| 348 | ).runningTerminalIds, | ||
| 349 | ).toEqual(["terminal-2"]); | ||
| 350 | |||
| 351 | store.applyTerminalEvent( | ||
| 352 | THREAD_REF, | ||
| 353 | makeTerminalEvent("exited", { | ||
| 354 | terminalId: "terminal-2", | ||
| 355 | exitCode: 0, | ||
| 356 | exitSignal: null, | ||
| 357 | }), | ||
| 358 | ); | ||
| 359 | |||
| 360 | const terminalState = selectThreadTerminalState( | ||
| 361 | useTerminalStateStore.getState().terminalStateByThreadKey, | ||
| 362 | THREAD_REF, | ||
| 363 | ); | ||
| 364 | const entries = selectTerminalEventEntries( | ||
| 365 | useTerminalStateStore.getState().terminalEventEntriesByKey, | ||
| 366 | THREAD_REF, | ||
| 367 | "terminal-2", | ||
| 368 | ); | ||
| 369 | |||
| 370 | expect(terminalState.runningTerminalIds).toEqual([]); | ||
| 371 | expect(entries.map((entry) => entry.event.type)).toEqual(["activity", "exited"]); | ||
| 372 | }); | ||
| 373 | |||
| 374 | it("clears buffered terminal events when a thread terminal state is removed", () => { | ||
| 375 | const store = useTerminalStateStore.getState(); | ||
| 376 | store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output")); | ||
| 377 | store.removeTerminalState(THREAD_REF); | ||
| 378 | |||
| 379 | const entries = selectTerminalEventEntries( | ||
| 380 | useTerminalStateStore.getState().terminalEventEntriesByKey, | ||
| 381 | THREAD_REF, | ||
| 382 | "default", | ||
| 383 | ); | ||
| 384 | |||
| 385 | expect(entries).toEqual([]); | ||
| 386 | }); | ||
| 387 | |||
| 388 | it("is a no-op when clearing terminal state for a thread with no state or buffered events", () => { | ||
| 389 | const store = useTerminalStateStore.getState(); | ||
| 390 | const before = useTerminalStateStore.getState(); | ||
| 391 | |||
| 392 | store.clearTerminalState(THREAD_REF); | ||
| 393 | |||
| 394 | expect(useTerminalStateStore.getState()).toBe(before); | ||
| 395 | }); | ||
| 396 | }); | ||
apps/web/src/terminalStateStore.ts deleted-847| ... | @@ -1,847 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Single Zustand store for terminal UI state keyed by scoped thread identity. | ||
| 3 | * | ||
| 4 | * Terminal transition helpers are intentionally private to keep the public | ||
| 5 | * API constrained to store actions/selectors. | ||
| 6 | */ | ||
| 7 | |||
| 8 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; | ||
| 9 | import { type ScopedThreadRef, type TerminalEvent } from "@t3tools/contracts"; | ||
| 10 | import { create } from "zustand"; | ||
| 11 | import { createJSONStorage, persist } from "zustand/middleware"; | ||
| 12 | import { resolveStorage } from "./lib/storage"; | ||
| 13 | import { terminalRunningSubprocessFromEvent } from "./terminalActivity"; | ||
| 14 | import { | ||
| 15 | DEFAULT_THREAD_TERMINAL_HEIGHT, | ||
| 16 | DEFAULT_THREAD_TERMINAL_ID, | ||
| 17 | MAX_TERMINALS_PER_GROUP, | ||
| 18 | type ThreadTerminalGroup, | ||
| 19 | } from "./types"; | ||
| 20 | |||
| 21 | interface ThreadTerminalState { | ||
| 22 | terminalOpen: boolean; | ||
| 23 | terminalHeight: number; | ||
| 24 | terminalIds: string[]; | ||
| 25 | runningTerminalIds: string[]; | ||
| 26 | activeTerminalId: string; | ||
| 27 | terminalGroups: ThreadTerminalGroup[]; | ||
| 28 | activeTerminalGroupId: string; | ||
| 29 | } | ||
| 30 | |||
| 31 | export interface ThreadTerminalLaunchContext { | ||
| 32 | cwd: string; | ||
| 33 | worktreePath: string | null; | ||
| 34 | } | ||
| 35 | |||
| 36 | export interface TerminalEventEntry { | ||
| 37 | id: number; | ||
| 38 | event: TerminalEvent; | ||
| 39 | } | ||
| 40 | |||
| 41 | const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; | ||
| 42 | const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = []; | ||
| 43 | const MAX_TERMINAL_EVENT_BUFFER = 200; | ||
| 44 | |||
| 45 | interface PersistedTerminalStateStoreState { | ||
| 46 | terminalStateByThreadKey?: Record<string, ThreadTerminalState>; | ||
| 47 | } | ||
| 48 | |||
| 49 | export function migratePersistedTerminalStateStoreState( | ||
| 50 | persistedState: unknown, | ||
| 51 | version: number, | ||
| 52 | ): PersistedTerminalStateStoreState { | ||
| 53 | if (version === 1 && persistedState && typeof persistedState === "object") { | ||
| 54 | const candidate = persistedState as PersistedTerminalStateStoreState; | ||
| 55 | const nextTerminalStateByThreadKey = Object.fromEntries( | ||
| 56 | Object.entries(candidate.terminalStateByThreadKey ?? {}).filter(([threadKey]) => | ||
| 57 | parseScopedThreadKey(threadKey), | ||
| 58 | ), | ||
| 59 | ); | ||
| 60 | return { terminalStateByThreadKey: nextTerminalStateByThreadKey }; | ||
| 61 | } | ||
| 62 | return { terminalStateByThreadKey: {} }; | ||
| 63 | } | ||
| 64 | |||
| 65 | function createTerminalStateStorage() { | ||
| 66 | return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); | ||
| 67 | } | ||
| 68 | |||
| 69 | function normalizeTerminalIds(terminalIds: string[]): string[] { | ||
| 70 | const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | ||
| 71 | return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; | ||
| 72 | } | ||
| 73 | |||
| 74 | function normalizeRunningTerminalIds( | ||
| 75 | runningTerminalIds: string[], | ||
| 76 | terminalIds: string[], | ||
| 77 | ): string[] { | ||
| 78 | if (runningTerminalIds.length === 0) return []; | ||
| 79 | const validTerminalIdSet = new Set(terminalIds); | ||
| 80 | return [...new Set(runningTerminalIds)] | ||
| 81 | .map((id) => id.trim()) | ||
| 82 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); | ||
| 83 | } | ||
| 84 | |||
| 85 | function fallbackGroupId(terminalId: string): string { | ||
| 86 | return `group-${terminalId}`; | ||
| 87 | } | ||
| 88 | |||
| 89 | function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string { | ||
| 90 | let candidate = baseId; | ||
| 91 | let index = 2; | ||
| 92 | while (usedGroupIds.has(candidate)) { | ||
| 93 | candidate = `${baseId}-${index}`; | ||
| 94 | index += 1; | ||
| 95 | } | ||
| 96 | usedGroupIds.add(candidate); | ||
| 97 | return candidate; | ||
| 98 | } | ||
| 99 | |||
| 100 | function findGroupIndexByTerminalId( | ||
| 101 | terminalGroups: ThreadTerminalGroup[], | ||
| 102 | terminalId: string, | ||
| 103 | ): number { | ||
| 104 | return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); | ||
| 105 | } | ||
| 106 | |||
| 107 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { | ||
| 108 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | ||
| 109 | } | ||
| 110 | |||
| 111 | function normalizeTerminalGroups( | ||
| 112 | terminalGroups: ThreadTerminalGroup[], | ||
| 113 | terminalIds: string[], | ||
| 114 | ): ThreadTerminalGroup[] { | ||
| 115 | const validTerminalIdSet = new Set(terminalIds); | ||
| 116 | const assignedTerminalIds = new Set<string>(); | ||
| 117 | const nextGroups: ThreadTerminalGroup[] = []; | ||
| 118 | const usedGroupIds = new Set<string>(); | ||
| 119 | |||
| 120 | for (const group of terminalGroups) { | ||
| 121 | const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { | ||
| 122 | if (!validTerminalIdSet.has(terminalId)) return false; | ||
| 123 | if (assignedTerminalIds.has(terminalId)) return false; | ||
| 124 | return true; | ||
| 125 | }); | ||
| 126 | if (groupTerminalIds.length === 0) continue; | ||
| 127 | for (const terminalId of groupTerminalIds) { | ||
| 128 | assignedTerminalIds.add(terminalId); | ||
| 129 | } | ||
| 130 | const baseGroupId = | ||
| 131 | group.id.trim().length > 0 | ||
| 132 | ? group.id.trim() | ||
| 133 | : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | ||
| 134 | nextGroups.push({ | ||
| 135 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), | ||
| 136 | terminalIds: groupTerminalIds, | ||
| 137 | }); | ||
| 138 | } | ||
| 139 | |||
| 140 | for (const terminalId of terminalIds) { | ||
| 141 | if (assignedTerminalIds.has(terminalId)) continue; | ||
| 142 | nextGroups.push({ | ||
| 143 | id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), | ||
| 144 | terminalIds: [terminalId], | ||
| 145 | }); | ||
| 146 | } | ||
| 147 | |||
| 148 | if (nextGroups.length === 0) { | ||
| 149 | return [ | ||
| 150 | { | ||
| 151 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 152 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 153 | }, | ||
| 154 | ]; | ||
| 155 | } | ||
| 156 | |||
| 157 | return nextGroups; | ||
| 158 | } | ||
| 159 | |||
| 160 | function arraysEqual(a: string[], b: string[]): boolean { | ||
| 161 | if (a.length !== b.length) return false; | ||
| 162 | for (let index = 0; index < a.length; index += 1) { | ||
| 163 | if (a[index] !== b[index]) return false; | ||
| 164 | } | ||
| 165 | return true; | ||
| 166 | } | ||
| 167 | |||
| 168 | function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean { | ||
| 169 | if (left.length !== right.length) return false; | ||
| 170 | for (let index = 0; index < left.length; index += 1) { | ||
| 171 | const leftGroup = left[index]; | ||
| 172 | const rightGroup = right[index]; | ||
| 173 | if (!leftGroup || !rightGroup) return false; | ||
| 174 | if (leftGroup.id !== rightGroup.id) return false; | ||
| 175 | if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; | ||
| 176 | } | ||
| 177 | return true; | ||
| 178 | } | ||
| 179 | |||
| 180 | function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean { | ||
| 181 | return ( | ||
| 182 | left.terminalOpen === right.terminalOpen && | ||
| 183 | left.terminalHeight === right.terminalHeight && | ||
| 184 | left.activeTerminalId === right.activeTerminalId && | ||
| 185 | left.activeTerminalGroupId === right.activeTerminalGroupId && | ||
| 186 | arraysEqual(left.terminalIds, right.terminalIds) && | ||
| 187 | arraysEqual(left.runningTerminalIds, right.runningTerminalIds) && | ||
| 188 | terminalGroupsEqual(left.terminalGroups, right.terminalGroups) | ||
| 189 | ); | ||
| 190 | } | ||
| 191 | |||
| 192 | const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({ | ||
| 193 | terminalOpen: false, | ||
| 194 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | ||
| 195 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 196 | runningTerminalIds: [], | ||
| 197 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | ||
| 198 | terminalGroups: [ | ||
| 199 | { | ||
| 200 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 201 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 202 | }, | ||
| 203 | ], | ||
| 204 | activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 205 | }); | ||
| 206 | |||
| 207 | function createDefaultThreadTerminalState(): ThreadTerminalState { | ||
| 208 | return { | ||
| 209 | ...DEFAULT_THREAD_TERMINAL_STATE, | ||
| 210 | terminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.terminalIds], | ||
| 211 | runningTerminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.runningTerminalIds], | ||
| 212 | terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_STATE.terminalGroups), | ||
| 213 | }; | ||
| 214 | } | ||
| 215 | |||
| 216 | function getDefaultThreadTerminalState(): ThreadTerminalState { | ||
| 217 | return DEFAULT_THREAD_TERMINAL_STATE; | ||
| 218 | } | ||
| 219 | |||
| 220 | function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState { | ||
| 221 | const terminalIds = normalizeTerminalIds(state.terminalIds); | ||
| 222 | const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; | ||
| 223 | const runningTerminalIds = normalizeRunningTerminalIds(state.runningTerminalIds, nextTerminalIds); | ||
| 224 | const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) | ||
| 225 | ? state.activeTerminalId | ||
| 226 | : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | ||
| 227 | const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); | ||
| 228 | const activeGroupIdFromState = terminalGroups.some( | ||
| 229 | (group) => group.id === state.activeTerminalGroupId, | ||
| 230 | ) | ||
| 231 | ? state.activeTerminalGroupId | ||
| 232 | : null; | ||
| 233 | const activeGroupIdFromTerminal = | ||
| 234 | terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; | ||
| 235 | |||
| 236 | const normalized: ThreadTerminalState = { | ||
| 237 | terminalOpen: state.terminalOpen, | ||
| 238 | terminalHeight: | ||
| 239 | Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 | ||
| 240 | ? state.terminalHeight | ||
| 241 | : DEFAULT_THREAD_TERMINAL_HEIGHT, | ||
| 242 | terminalIds: nextTerminalIds, | ||
| 243 | runningTerminalIds, | ||
| 244 | activeTerminalId, | ||
| 245 | terminalGroups, | ||
| 246 | activeTerminalGroupId: | ||
| 247 | activeGroupIdFromState ?? | ||
| 248 | activeGroupIdFromTerminal ?? | ||
| 249 | terminalGroups[0]?.id ?? | ||
| 250 | fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | ||
| 251 | }; | ||
| 252 | return threadTerminalStateEqual(state, normalized) ? state : normalized; | ||
| 253 | } | ||
| 254 | |||
| 255 | function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean { | ||
| 256 | const normalized = normalizeThreadTerminalState(state); | ||
| 257 | return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE); | ||
| 258 | } | ||
| 259 | |||
| 260 | function isValidTerminalId(terminalId: string): boolean { | ||
| 261 | return terminalId.trim().length > 0; | ||
| 262 | } | ||
| 263 | |||
| 264 | function terminalThreadKey(threadRef: ScopedThreadRef): string { | ||
| 265 | return scopedThreadKey(threadRef); | ||
| 266 | } | ||
| 267 | |||
| 268 | function terminalEventBufferKey(threadRef: ScopedThreadRef, terminalId: string): string { | ||
| 269 | return `${terminalThreadKey(threadRef)}\u0000${terminalId}`; | ||
| 270 | } | ||
| 271 | |||
| 272 | function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { | ||
| 273 | return groups.map((group) => ({ | ||
| 274 | id: group.id, | ||
| 275 | terminalIds: [...group.terminalIds], | ||
| 276 | })); | ||
| 277 | } | ||
| 278 | |||
| 279 | function appendTerminalEventEntry( | ||
| 280 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>, | ||
| 281 | nextTerminalEventId: number, | ||
| 282 | threadRef: ScopedThreadRef, | ||
| 283 | event: TerminalEvent, | ||
| 284 | ) { | ||
| 285 | const key = terminalEventBufferKey(threadRef, event.terminalId); | ||
| 286 | const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; | ||
| 287 | const nextEntry: TerminalEventEntry = { | ||
| 288 | id: nextTerminalEventId, | ||
| 289 | event, | ||
| 290 | }; | ||
| 291 | const nextEntries = | ||
| 292 | currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER | ||
| 293 | ? [...currentEntries.slice(1), nextEntry] | ||
| 294 | : [...currentEntries, nextEntry]; | ||
| 295 | |||
| 296 | return { | ||
| 297 | terminalEventEntriesByKey: { | ||
| 298 | ...terminalEventEntriesByKey, | ||
| 299 | [key]: nextEntries, | ||
| 300 | }, | ||
| 301 | nextTerminalEventId: nextTerminalEventId + 1, | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | function launchContextFromStartEvent( | ||
| 306 | event: Extract<TerminalEvent, { type: "started" | "restarted" }>, | ||
| 307 | ): ThreadTerminalLaunchContext { | ||
| 308 | return { | ||
| 309 | cwd: event.snapshot.cwd, | ||
| 310 | worktreePath: event.snapshot.worktreePath, | ||
| 311 | }; | ||
| 312 | } | ||
| 313 | |||
| 314 | function upsertTerminalIntoGroups( | ||
| 315 | state: ThreadTerminalState, | ||
| 316 | terminalId: string, | ||
| 317 | mode: "split" | "new", | ||
| 318 | ): ThreadTerminalState { | ||
| 319 | const normalized = normalizeThreadTerminalState(state); | ||
| 320 | if (!isValidTerminalId(terminalId)) { | ||
| 321 | return normalized; | ||
| 322 | } | ||
| 323 | |||
| 324 | const isNewTerminal = !normalized.terminalIds.includes(terminalId); | ||
| 325 | const terminalIds = isNewTerminal | ||
| 326 | ? [...normalized.terminalIds, terminalId] | ||
| 327 | : normalized.terminalIds; | ||
| 328 | const terminalGroups = copyTerminalGroups(normalized.terminalGroups); | ||
| 329 | |||
| 330 | const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); | ||
| 331 | if (existingGroupIndex >= 0) { | ||
| 332 | terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ | ||
| 333 | existingGroupIndex | ||
| 334 | ]!.terminalIds.filter((id) => id !== terminalId); | ||
| 335 | if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { | ||
| 336 | terminalGroups.splice(existingGroupIndex, 1); | ||
| 337 | } | ||
| 338 | } | ||
| 339 | |||
| 340 | if (mode === "new") { | ||
| 341 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | ||
| 342 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); | ||
| 343 | terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); | ||
| 344 | return normalizeThreadTerminalState({ | ||
| 345 | ...normalized, | ||
| 346 | terminalOpen: true, | ||
| 347 | terminalIds, | ||
| 348 | activeTerminalId: terminalId, | ||
| 349 | terminalGroups, | ||
| 350 | activeTerminalGroupId: nextGroupId, | ||
| 351 | }); | ||
| 352 | } | ||
| 353 | |||
| 354 | let activeGroupIndex = terminalGroups.findIndex( | ||
| 355 | (group) => group.id === normalized.activeTerminalGroupId, | ||
| 356 | ); | ||
| 357 | if (activeGroupIndex < 0) { | ||
| 358 | activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); | ||
| 359 | } | ||
| 360 | if (activeGroupIndex < 0) { | ||
| 361 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | ||
| 362 | const nextGroupId = assignUniqueGroupId( | ||
| 363 | fallbackGroupId(normalized.activeTerminalId), | ||
| 364 | usedGroupIds, | ||
| 365 | ); | ||
| 366 | terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); | ||
| 367 | activeGroupIndex = terminalGroups.length - 1; | ||
| 368 | } | ||
| 369 | |||
| 370 | const destinationGroup = terminalGroups[activeGroupIndex]; | ||
| 371 | if (!destinationGroup) { | ||
| 372 | return normalized; | ||
| 373 | } | ||
| 374 | |||
| 375 | if ( | ||
| 376 | isNewTerminal && | ||
| 377 | !destinationGroup.terminalIds.includes(terminalId) && | ||
| 378 | destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP | ||
| 379 | ) { | ||
| 380 | return normalized; | ||
| 381 | } | ||
| 382 | |||
| 383 | if (!destinationGroup.terminalIds.includes(terminalId)) { | ||
| 384 | const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); | ||
| 385 | if (anchorIndex >= 0) { | ||
| 386 | destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); | ||
| 387 | } else { | ||
| 388 | destinationGroup.terminalIds.push(terminalId); | ||
| 389 | } | ||
| 390 | } | ||
| 391 | |||
| 392 | return normalizeThreadTerminalState({ | ||
| 393 | ...normalized, | ||
| 394 | terminalOpen: true, | ||
| 395 | terminalIds, | ||
| 396 | activeTerminalId: terminalId, | ||
| 397 | terminalGroups, | ||
| 398 | activeTerminalGroupId: destinationGroup.id, | ||
| 399 | }); | ||
| 400 | } | ||
| 401 | |||
| 402 | function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState { | ||
| 403 | const normalized = normalizeThreadTerminalState(state); | ||
| 404 | if (normalized.terminalOpen === open) return normalized; | ||
| 405 | return { ...normalized, terminalOpen: open }; | ||
| 406 | } | ||
| 407 | |||
| 408 | function setThreadTerminalHeight(state: ThreadTerminalState, height: number): ThreadTerminalState { | ||
| 409 | const normalized = normalizeThreadTerminalState(state); | ||
| 410 | if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { | ||
| 411 | return normalized; | ||
| 412 | } | ||
| 413 | return { ...normalized, terminalHeight: height }; | ||
| 414 | } | ||
| 415 | |||
| 416 | function splitThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { | ||
| 417 | return upsertTerminalIntoGroups(state, terminalId, "split"); | ||
| 418 | } | ||
| 419 | |||
| 420 | function newThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { | ||
| 421 | return upsertTerminalIntoGroups(state, terminalId, "new"); | ||
| 422 | } | ||
| 423 | |||
| 424 | function setThreadActiveTerminal( | ||
| 425 | state: ThreadTerminalState, | ||
| 426 | terminalId: string, | ||
| 427 | ): ThreadTerminalState { | ||
| 428 | const normalized = normalizeThreadTerminalState(state); | ||
| 429 | if (!normalized.terminalIds.includes(terminalId)) { | ||
| 430 | return normalized; | ||
| 431 | } | ||
| 432 | const activeTerminalGroupId = | ||
| 433 | normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ?? | ||
| 434 | normalized.activeTerminalGroupId; | ||
| 435 | if ( | ||
| 436 | normalized.activeTerminalId === terminalId && | ||
| 437 | normalized.activeTerminalGroupId === activeTerminalGroupId | ||
| 438 | ) { | ||
| 439 | return normalized; | ||
| 440 | } | ||
| 441 | return { | ||
| 442 | ...normalized, | ||
| 443 | activeTerminalId: terminalId, | ||
| 444 | activeTerminalGroupId, | ||
| 445 | }; | ||
| 446 | } | ||
| 447 | |||
| 448 | function closeThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { | ||
| 449 | const normalized = normalizeThreadTerminalState(state); | ||
| 450 | if (!normalized.terminalIds.includes(terminalId)) { | ||
| 451 | return normalized; | ||
| 452 | } | ||
| 453 | |||
| 454 | const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); | ||
| 455 | if (remainingTerminalIds.length === 0) { | ||
| 456 | return createDefaultThreadTerminalState(); | ||
| 457 | } | ||
| 458 | |||
| 459 | const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); | ||
| 460 | const nextActiveTerminalId = | ||
| 461 | normalized.activeTerminalId === terminalId | ||
| 462 | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? | ||
| 463 | remainingTerminalIds[0] ?? | ||
| 464 | DEFAULT_THREAD_TERMINAL_ID) | ||
| 465 | : normalized.activeTerminalId; | ||
| 466 | |||
| 467 | const terminalGroups = normalized.terminalGroups | ||
| 468 | .map((group) => ({ | ||
| 469 | ...group, | ||
| 470 | terminalIds: group.terminalIds.filter((id) => id !== terminalId), | ||
| 471 | })) | ||
| 472 | .filter((group) => group.terminalIds.length > 0); | ||
| 473 | |||
| 474 | const nextActiveTerminalGroupId = | ||
| 475 | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? | ||
| 476 | terminalGroups[0]?.id ?? | ||
| 477 | fallbackGroupId(nextActiveTerminalId); | ||
| 478 | |||
| 479 | return normalizeThreadTerminalState({ | ||
| 480 | terminalOpen: normalized.terminalOpen, | ||
| 481 | terminalHeight: normalized.terminalHeight, | ||
| 482 | terminalIds: remainingTerminalIds, | ||
| 483 | runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId), | ||
| 484 | activeTerminalId: nextActiveTerminalId, | ||
| 485 | terminalGroups, | ||
| 486 | activeTerminalGroupId: nextActiveTerminalGroupId, | ||
| 487 | }); | ||
| 488 | } | ||
| 489 | |||
| 490 | function setThreadTerminalActivity( | ||
| 491 | state: ThreadTerminalState, | ||
| 492 | terminalId: string, | ||
| 493 | hasRunningSubprocess: boolean, | ||
| 494 | ): ThreadTerminalState { | ||
| 495 | const normalized = normalizeThreadTerminalState(state); | ||
| 496 | if (!normalized.terminalIds.includes(terminalId)) { | ||
| 497 | return normalized; | ||
| 498 | } | ||
| 499 | const alreadyRunning = normalized.runningTerminalIds.includes(terminalId); | ||
| 500 | if (hasRunningSubprocess === alreadyRunning) { | ||
| 501 | return normalized; | ||
| 502 | } | ||
| 503 | const runningTerminalIds = new Set(normalized.runningTerminalIds); | ||
| 504 | if (hasRunningSubprocess) { | ||
| 505 | runningTerminalIds.add(terminalId); | ||
| 506 | } else { | ||
| 507 | runningTerminalIds.delete(terminalId); | ||
| 508 | } | ||
| 509 | return { ...normalized, runningTerminalIds: [...runningTerminalIds] }; | ||
| 510 | } | ||
| 511 | |||
| 512 | export function selectThreadTerminalState( | ||
| 513 | terminalStateByThreadKey: Record<string, ThreadTerminalState>, | ||
| 514 | threadRef: ScopedThreadRef | null | undefined, | ||
| 515 | ): ThreadTerminalState { | ||
| 516 | if (!threadRef || threadRef.threadId.length === 0) { | ||
| 517 | return getDefaultThreadTerminalState(); | ||
| 518 | } | ||
| 519 | return terminalStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalState(); | ||
| 520 | } | ||
| 521 | |||
| 522 | function updateTerminalStateByThreadKey( | ||
| 523 | terminalStateByThreadKey: Record<string, ThreadTerminalState>, | ||
| 524 | threadRef: ScopedThreadRef, | ||
| 525 | updater: (state: ThreadTerminalState) => ThreadTerminalState, | ||
| 526 | ): Record<string, ThreadTerminalState> { | ||
| 527 | if (threadRef.threadId.length === 0) { | ||
| 528 | return terminalStateByThreadKey; | ||
| 529 | } | ||
| 530 | |||
| 531 | const threadKey = terminalThreadKey(threadRef); | ||
| 532 | const current = selectThreadTerminalState(terminalStateByThreadKey, threadRef); | ||
| 533 | const next = updater(current); | ||
| 534 | if (next === current) { | ||
| 535 | return terminalStateByThreadKey; | ||
| 536 | } | ||
| 537 | |||
| 538 | if (isDefaultThreadTerminalState(next)) { | ||
| 539 | if (terminalStateByThreadKey[threadKey] === undefined) { | ||
| 540 | return terminalStateByThreadKey; | ||
| 541 | } | ||
| 542 | const { [threadKey]: _removed, ...rest } = terminalStateByThreadKey; | ||
| 543 | return rest; | ||
| 544 | } | ||
| 545 | |||
| 546 | return { | ||
| 547 | ...terminalStateByThreadKey, | ||
| 548 | [threadKey]: next, | ||
| 549 | }; | ||
| 550 | } | ||
| 551 | |||
| 552 | export function selectTerminalEventEntries( | ||
| 553 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>, | ||
| 554 | threadRef: ScopedThreadRef | null | undefined, | ||
| 555 | terminalId: string, | ||
| 556 | ): ReadonlyArray<TerminalEventEntry> { | ||
| 557 | if (!threadRef || threadRef.threadId.length === 0 || terminalId.trim().length === 0) { | ||
| 558 | return EMPTY_TERMINAL_EVENT_ENTRIES; | ||
| 559 | } | ||
| 560 | return ( | ||
| 561 | terminalEventEntriesByKey[terminalEventBufferKey(threadRef, terminalId)] ?? | ||
| 562 | EMPTY_TERMINAL_EVENT_ENTRIES | ||
| 563 | ); | ||
| 564 | } | ||
| 565 | |||
| 566 | interface TerminalStateStoreState { | ||
| 567 | terminalStateByThreadKey: Record<string, ThreadTerminalState>; | ||
| 568 | terminalLaunchContextByThreadKey: Record<string, ThreadTerminalLaunchContext>; | ||
| 569 | terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>; | ||
| 570 | nextTerminalEventId: number; | ||
| 571 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; | ||
| 572 | setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; | ||
| 573 | splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | ||
| 574 | newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | ||
| 575 | ensureTerminal: ( | ||
| 576 | threadRef: ScopedThreadRef, | ||
| 577 | terminalId: string, | ||
| 578 | options?: { open?: boolean; active?: boolean }, | ||
| 579 | ) => void; | ||
| 580 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | ||
| 581 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | ||
| 582 | setTerminalLaunchContext: ( | ||
| 583 | threadRef: ScopedThreadRef, | ||
| 584 | context: ThreadTerminalLaunchContext, | ||
| 585 | ) => void; | ||
| 586 | clearTerminalLaunchContext: (threadRef: ScopedThreadRef) => void; | ||
| 587 | setTerminalActivity: ( | ||
| 588 | threadRef: ScopedThreadRef, | ||
| 589 | terminalId: string, | ||
| 590 | hasRunningSubprocess: boolean, | ||
| 591 | ) => void; | ||
| 592 | recordTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; | ||
| 593 | applyTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; | ||
| 594 | clearTerminalState: (threadRef: ScopedThreadRef) => void; | ||
| 595 | removeTerminalState: (threadRef: ScopedThreadRef) => void; | ||
| 596 | removeOrphanedTerminalStates: (activeThreadKeys: Set<string>) => void; | ||
| 597 | } | ||
| 598 | |||
| 599 | export const useTerminalStateStore = create<TerminalStateStoreState>()( | ||
| 600 | persist( | ||
| 601 | (set) => { | ||
| 602 | const updateTerminal = ( | ||
| 603 | threadRef: ScopedThreadRef, | ||
| 604 | updater: (state: ThreadTerminalState) => ThreadTerminalState, | ||
| 605 | ) => { | ||
| 606 | set((state) => { | ||
| 607 | const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( | ||
| 608 | state.terminalStateByThreadKey, | ||
| 609 | threadRef, | ||
| 610 | updater, | ||
| 611 | ); | ||
| 612 | if (nextTerminalStateByThreadKey === state.terminalStateByThreadKey) { | ||
| 613 | return state; | ||
| 614 | } | ||
| 615 | return { | ||
| 616 | terminalStateByThreadKey: nextTerminalStateByThreadKey, | ||
| 617 | }; | ||
| 618 | }); | ||
| 619 | }; | ||
| 620 | |||
| 621 | return { | ||
| 622 | terminalStateByThreadKey: {}, | ||
| 623 | terminalLaunchContextByThreadKey: {}, | ||
| 624 | terminalEventEntriesByKey: {}, | ||
| 625 | nextTerminalEventId: 1, | ||
| 626 | setTerminalOpen: (threadRef, open) => | ||
| 627 | updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)), | ||
| 628 | setTerminalHeight: (threadRef, height) => | ||
| 629 | updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), | ||
| 630 | splitTerminal: (threadRef, terminalId) => | ||
| 631 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), | ||
| 632 | newTerminal: (threadRef, terminalId) => | ||
| 633 | updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), | ||
| 634 | ensureTerminal: (threadRef, terminalId, options) => | ||
| 635 | updateTerminal(threadRef, (state) => { | ||
| 636 | let nextState = state; | ||
| 637 | if (!state.terminalIds.includes(terminalId)) { | ||
| 638 | nextState = newThreadTerminal(nextState, terminalId); | ||
| 639 | } | ||
| 640 | if (options?.active === false) { | ||
| 641 | nextState = { | ||
| 642 | ...nextState, | ||
| 643 | activeTerminalId: state.activeTerminalId, | ||
| 644 | activeTerminalGroupId: state.activeTerminalGroupId, | ||
| 645 | }; | ||
| 646 | } | ||
| 647 | if (options?.active ?? true) { | ||
| 648 | nextState = setThreadActiveTerminal(nextState, terminalId); | ||
| 649 | } | ||
| 650 | if (options?.open) { | ||
| 651 | nextState = setThreadTerminalOpen(nextState, true); | ||
| 652 | } | ||
| 653 | return normalizeThreadTerminalState(nextState); | ||
| 654 | }), | ||
| 655 | setActiveTerminal: (threadRef, terminalId) => | ||
| 656 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), | ||
| 657 | closeTerminal: (threadRef, terminalId) => | ||
| 658 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), | ||
| 659 | setTerminalLaunchContext: (threadRef, context) => | ||
| 660 | set((state) => ({ | ||
| 661 | terminalLaunchContextByThreadKey: { | ||
| 662 | ...state.terminalLaunchContextByThreadKey, | ||
| 663 | [terminalThreadKey(threadRef)]: context, | ||
| 664 | }, | ||
| 665 | })), | ||
| 666 | clearTerminalLaunchContext: (threadRef) => | ||
| 667 | set((state) => { | ||
| 668 | const threadKey = terminalThreadKey(threadRef); | ||
| 669 | if (!state.terminalLaunchContextByThreadKey[threadKey]) { | ||
| 670 | return state; | ||
| 671 | } | ||
| 672 | const { [threadKey]: _removed, ...rest } = state.terminalLaunchContextByThreadKey; | ||
| 673 | return { terminalLaunchContextByThreadKey: rest }; | ||
| 674 | }), | ||
| 675 | setTerminalActivity: (threadRef, terminalId, hasRunningSubprocess) => | ||
| 676 | updateTerminal(threadRef, (state) => | ||
| 677 | setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), | ||
| 678 | ), | ||
| 679 | recordTerminalEvent: (threadRef, event) => | ||
| 680 | set((state) => | ||
| 681 | appendTerminalEventEntry( | ||
| 682 | state.terminalEventEntriesByKey, | ||
| 683 | state.nextTerminalEventId, | ||
| 684 | threadRef, | ||
| 685 | event, | ||
| 686 | ), | ||
| 687 | ), | ||
| 688 | applyTerminalEvent: (threadRef, event) => | ||
| 689 | set((state) => { | ||
| 690 | const threadKey = terminalThreadKey(threadRef); | ||
| 691 | let nextTerminalStateByThreadKey = state.terminalStateByThreadKey; | ||
| 692 | let nextTerminalLaunchContextByThreadKey = state.terminalLaunchContextByThreadKey; | ||
| 693 | |||
| 694 | if (event.type === "started" || event.type === "restarted") { | ||
| 695 | nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( | ||
| 696 | nextTerminalStateByThreadKey, | ||
| 697 | threadRef, | ||
| 698 | (current) => { | ||
| 699 | let nextState = current; | ||
| 700 | if (!current.terminalIds.includes(event.terminalId)) { | ||
| 701 | nextState = newThreadTerminal(nextState, event.terminalId); | ||
| 702 | } | ||
| 703 | nextState = setThreadActiveTerminal(nextState, event.terminalId); | ||
| 704 | nextState = setThreadTerminalOpen(nextState, true); | ||
| 705 | return normalizeThreadTerminalState(nextState); | ||
| 706 | }, | ||
| 707 | ); | ||
| 708 | nextTerminalLaunchContextByThreadKey = { | ||
| 709 | ...nextTerminalLaunchContextByThreadKey, | ||
| 710 | [threadKey]: launchContextFromStartEvent(event), | ||
| 711 | }; | ||
| 712 | } | ||
| 713 | |||
| 714 | const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); | ||
| 715 | if (hasRunningSubprocess !== null) { | ||
| 716 | nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( | ||
| 717 | nextTerminalStateByThreadKey, | ||
| 718 | threadRef, | ||
| 719 | (current) => | ||
| 720 | setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess), | ||
| 721 | ); | ||
| 722 | } | ||
| 723 | |||
| 724 | const nextEventState = appendTerminalEventEntry( | ||
| 725 | state.terminalEventEntriesByKey, | ||
| 726 | state.nextTerminalEventId, | ||
| 727 | threadRef, | ||
| 728 | event, | ||
| 729 | ); | ||
| 730 | |||
| 731 | return { | ||
| 732 | terminalStateByThreadKey: nextTerminalStateByThreadKey, | ||
| 733 | terminalLaunchContextByThreadKey: nextTerminalLaunchContextByThreadKey, | ||
| 734 | ...nextEventState, | ||
| 735 | }; | ||
| 736 | }), | ||
| 737 | clearTerminalState: (threadRef) => | ||
| 738 | set((state) => { | ||
| 739 | const threadKey = terminalThreadKey(threadRef); | ||
| 740 | const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( | ||
| 741 | state.terminalStateByThreadKey, | ||
| 742 | threadRef, | ||
| 743 | () => createDefaultThreadTerminalState(), | ||
| 744 | ); | ||
| 745 | const hadLaunchContext = | ||
| 746 | state.terminalLaunchContextByThreadKey[threadKey] !== undefined; | ||
| 747 | const { [threadKey]: _removed, ...remainingLaunchContexts } = | ||
| 748 | state.terminalLaunchContextByThreadKey; | ||
| 749 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; | ||
| 750 | let removedEventEntries = false; | ||
| 751 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { | ||
| 752 | if (key.startsWith(`${threadKey}\u0000`)) { | ||
| 753 | delete nextTerminalEventEntriesByKey[key]; | ||
| 754 | removedEventEntries = true; | ||
| 755 | } | ||
| 756 | } | ||
| 757 | if ( | ||
| 758 | nextTerminalStateByThreadKey === state.terminalStateByThreadKey && | ||
| 759 | !hadLaunchContext && | ||
| 760 | !removedEventEntries | ||
| 761 | ) { | ||
| 762 | return state; | ||
| 763 | } | ||
| 764 | return { | ||
| 765 | terminalStateByThreadKey: nextTerminalStateByThreadKey, | ||
| 766 | terminalLaunchContextByThreadKey: remainingLaunchContexts, | ||
| 767 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, | ||
| 768 | }; | ||
| 769 | }), | ||
| 770 | removeTerminalState: (threadRef) => | ||
| 771 | set((state) => { | ||
| 772 | const threadKey = terminalThreadKey(threadRef); | ||
| 773 | const hadTerminalState = state.terminalStateByThreadKey[threadKey] !== undefined; | ||
| 774 | const hadLaunchContext = | ||
| 775 | state.terminalLaunchContextByThreadKey[threadKey] !== undefined; | ||
| 776 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; | ||
| 777 | let removedEventEntries = false; | ||
| 778 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { | ||
| 779 | if (key.startsWith(`${threadKey}\u0000`)) { | ||
| 780 | delete nextTerminalEventEntriesByKey[key]; | ||
| 781 | removedEventEntries = true; | ||
| 782 | } | ||
| 783 | } | ||
| 784 | if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) { | ||
| 785 | return state; | ||
| 786 | } | ||
| 787 | const nextTerminalStateByThreadKey = { ...state.terminalStateByThreadKey }; | ||
| 788 | delete nextTerminalStateByThreadKey[threadKey]; | ||
| 789 | const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey }; | ||
| 790 | delete nextLaunchContexts[threadKey]; | ||
| 791 | return { | ||
| 792 | terminalStateByThreadKey: nextTerminalStateByThreadKey, | ||
| 793 | terminalLaunchContextByThreadKey: nextLaunchContexts, | ||
| 794 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, | ||
| 795 | }; | ||
| 796 | }), | ||
| 797 | removeOrphanedTerminalStates: (activeThreadKeys) => | ||
| 798 | set((state) => { | ||
| 799 | const orphanedIds = Object.keys(state.terminalStateByThreadKey).filter( | ||
| 800 | (key) => !activeThreadKeys.has(key), | ||
| 801 | ); | ||
| 802 | const orphanedLaunchContextIds = Object.keys( | ||
| 803 | state.terminalLaunchContextByThreadKey, | ||
| 804 | ).filter((key) => !activeThreadKeys.has(key)); | ||
| 805 | const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; | ||
| 806 | let removedEventEntries = false; | ||
| 807 | for (const key of Object.keys(nextTerminalEventEntriesByKey)) { | ||
| 808 | const [threadKey] = key.split("\u0000"); | ||
| 809 | if (threadKey && !activeThreadKeys.has(threadKey)) { | ||
| 810 | delete nextTerminalEventEntriesByKey[key]; | ||
| 811 | removedEventEntries = true; | ||
| 812 | } | ||
| 813 | } | ||
| 814 | if ( | ||
| 815 | orphanedIds.length === 0 && | ||
| 816 | orphanedLaunchContextIds.length === 0 && | ||
| 817 | !removedEventEntries | ||
| 818 | ) { | ||
| 819 | return state; | ||
| 820 | } | ||
| 821 | const next = { ...state.terminalStateByThreadKey }; | ||
| 822 | for (const id of orphanedIds) { | ||
| 823 | delete next[id]; | ||
| 824 | } | ||
| 825 | const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey }; | ||
| 826 | for (const id of orphanedLaunchContextIds) { | ||
| 827 | delete nextLaunchContexts[id]; | ||
| 828 | } | ||
| 829 | return { | ||
| 830 | terminalStateByThreadKey: next, | ||
| 831 | terminalLaunchContextByThreadKey: nextLaunchContexts, | ||
| 832 | terminalEventEntriesByKey: nextTerminalEventEntriesByKey, | ||
| 833 | }; | ||
| 834 | }), | ||
| 835 | }; | ||
| 836 | }, | ||
| 837 | { | ||
| 838 | name: TERMINAL_STATE_STORAGE_KEY, | ||
| 839 | version: 2, | ||
| 840 | storage: createJSONStorage(createTerminalStateStorage), | ||
| 841 | migrate: migratePersistedTerminalStateStoreState, | ||
| 842 | partialize: (state) => ({ | ||
| 843 | terminalStateByThreadKey: state.terminalStateByThreadKey, | ||
| 844 | }), | ||
| 845 | }, | ||
| 846 | ), | ||
| 847 | ); | ||
apps/web/src/terminalUiStateStore.test.ts created+254| ... | @@ -0,0 +1,254 @@ | ||
| 1 | import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; | ||
| 2 | import { ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { beforeEach, describe, expect, it } from "vitest"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | migratePersistedTerminalUiStateStoreState, | ||
| 7 | selectThreadTerminalUiState, | ||
| 8 | useTerminalUiStateStore, | ||
| 9 | } from "./terminalUiStateStore"; | ||
| 10 | import { DEFAULT_THREAD_TERMINAL_ID } from "./types"; | ||
| 11 | |||
| 12 | const THREAD_ID = ThreadId.make("thread-1"); | ||
| 13 | const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID); | ||
| 14 | const OTHER_THREAD_REF = scopeThreadRef("environment-b" as never, THREAD_ID); | ||
| 15 | |||
| 16 | describe("terminalUiStateStore actions", () => { | ||
| 17 | beforeEach(() => { | ||
| 18 | useTerminalUiStateStore.persist.clearStorage(); | ||
| 19 | useTerminalUiStateStore.setState({ | ||
| 20 | terminalUiStateByThreadKey: {}, | ||
| 21 | }); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("returns an empty default terminal UI state for unknown threads", () => { | ||
| 25 | const terminalUiState = selectThreadTerminalUiState( | ||
| 26 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 27 | THREAD_REF, | ||
| 28 | ); | ||
| 29 | expect(terminalUiState).toEqual({ | ||
| 30 | terminalOpen: false, | ||
| 31 | terminalHeight: 280, | ||
| 32 | terminalIds: [], | ||
| 33 | activeTerminalId: "", | ||
| 34 | terminalGroups: [], | ||
| 35 | activeTerminalGroupId: "", | ||
| 36 | }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("opens and splits terminals into the active group", () => { | ||
| 40 | const store = useTerminalUiStateStore.getState(); | ||
| 41 | store.setTerminalOpen(THREAD_REF, true); | ||
| 42 | store.splitTerminal(THREAD_REF, "terminal-2"); | ||
| 43 | |||
| 44 | const terminalUiState = selectThreadTerminalUiState( | ||
| 45 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 46 | THREAD_REF, | ||
| 47 | ); | ||
| 48 | expect(terminalUiState.terminalOpen).toBe(true); | ||
| 49 | expect(terminalUiState.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "terminal-2"]); | ||
| 50 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); | ||
| 51 | expect(terminalUiState.terminalGroups).toEqual([ | ||
| 52 | { | ||
| 53 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 54 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "terminal-2"], | ||
| 55 | }, | ||
| 56 | ]); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("materializes the default terminal when opening an empty drawer", () => { | ||
| 60 | useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true); | ||
| 61 | |||
| 62 | const terminalUiState = selectThreadTerminalUiState( | ||
| 63 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 64 | THREAD_REF, | ||
| 65 | ); | ||
| 66 | expect(terminalUiState).toEqual({ | ||
| 67 | terminalOpen: true, | ||
| 68 | terminalHeight: 280, | ||
| 69 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 70 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | ||
| 71 | terminalGroups: [ | ||
| 72 | { | ||
| 73 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 74 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 75 | }, | ||
| 76 | ], | ||
| 77 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 78 | }); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("caps splits at four terminals per group", () => { | ||
| 82 | const store = useTerminalUiStateStore.getState(); | ||
| 83 | store.splitTerminal(THREAD_REF, "terminal-2"); | ||
| 84 | store.splitTerminal(THREAD_REF, "terminal-3"); | ||
| 85 | store.splitTerminal(THREAD_REF, "terminal-4"); | ||
| 86 | store.splitTerminal(THREAD_REF, "terminal-5"); | ||
| 87 | store.splitTerminal(THREAD_REF, "terminal-6"); | ||
| 88 | |||
| 89 | const terminalUiState = selectThreadTerminalUiState( | ||
| 90 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 91 | THREAD_REF, | ||
| 92 | ); | ||
| 93 | expect(terminalUiState.terminalIds).toEqual([ | ||
| 94 | "terminal-2", | ||
| 95 | "terminal-3", | ||
| 96 | "terminal-4", | ||
| 97 | "terminal-5", | ||
| 98 | ]); | ||
| 99 | expect(terminalUiState.terminalGroups).toEqual([ | ||
| 100 | { | ||
| 101 | id: "group-terminal-2", | ||
| 102 | terminalIds: ["terminal-2", "terminal-3", "terminal-4", "terminal-5"], | ||
| 103 | }, | ||
| 104 | ]); | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("creates new terminals in a separate group", () => { | ||
| 108 | useTerminalUiStateStore.getState().newTerminal(THREAD_REF, "terminal-2"); | ||
| 109 | |||
| 110 | const terminalUiState = selectThreadTerminalUiState( | ||
| 111 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 112 | THREAD_REF, | ||
| 113 | ); | ||
| 114 | expect(terminalUiState.terminalIds).toEqual(["terminal-2"]); | ||
| 115 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); | ||
| 116 | expect(terminalUiState.activeTerminalGroupId).toBe("group-terminal-2"); | ||
| 117 | expect(terminalUiState.terminalGroups).toEqual([ | ||
| 118 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, | ||
| 119 | ]); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("ensures unknown server terminals are registered, opened, and activated", () => { | ||
| 123 | const store = useTerminalUiStateStore.getState(); | ||
| 124 | store.ensureTerminal(THREAD_REF, "setup-setup", { open: true, active: true }); | ||
| 125 | |||
| 126 | const terminalUiState = selectThreadTerminalUiState( | ||
| 127 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 128 | THREAD_REF, | ||
| 129 | ); | ||
| 130 | expect(terminalUiState.terminalOpen).toBe(true); | ||
| 131 | expect(terminalUiState.terminalIds).toEqual(["setup-setup"]); | ||
| 132 | expect(terminalUiState.activeTerminalId).toBe("setup-setup"); | ||
| 133 | expect(terminalUiState.terminalGroups).toEqual([ | ||
| 134 | { id: "group-setup-setup", terminalIds: ["setup-setup"] }, | ||
| 135 | ]); | ||
| 136 | }); | ||
| 137 | |||
| 138 | it("keeps state isolated per environment when raw thread ids collide", () => { | ||
| 139 | const store = useTerminalUiStateStore.getState(); | ||
| 140 | store.setTerminalOpen(THREAD_REF, true); | ||
| 141 | store.newTerminal(OTHER_THREAD_REF, "env-b-terminal"); | ||
| 142 | |||
| 143 | expect( | ||
| 144 | selectThreadTerminalUiState( | ||
| 145 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 146 | THREAD_REF, | ||
| 147 | ).terminalOpen, | ||
| 148 | ).toBe(true); | ||
| 149 | expect( | ||
| 150 | selectThreadTerminalUiState( | ||
| 151 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 152 | OTHER_THREAD_REF, | ||
| 153 | ).terminalIds, | ||
| 154 | ).toEqual(["env-b-terminal"]); | ||
| 155 | }); | ||
| 156 | |||
| 157 | it("drops persisted entries whose thread keys are not valid scoped keys", () => { | ||
| 158 | const migrated = migratePersistedTerminalUiStateStoreState( | ||
| 159 | { | ||
| 160 | terminalStateByThreadKey: { | ||
| 161 | [scopedThreadKey(THREAD_REF)]: { | ||
| 162 | terminalOpen: true, | ||
| 163 | terminalHeight: 320, | ||
| 164 | terminalIds: ["term-1"], | ||
| 165 | activeTerminalId: "term-1", | ||
| 166 | terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], | ||
| 167 | activeTerminalGroupId: "group-term-1", | ||
| 168 | }, | ||
| 169 | "legacy-thread-id": { | ||
| 170 | terminalOpen: true, | ||
| 171 | terminalHeight: 320, | ||
| 172 | terminalIds: ["term-1"], | ||
| 173 | activeTerminalId: "term-1", | ||
| 174 | terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], | ||
| 175 | activeTerminalGroupId: "group-term-1", | ||
| 176 | }, | ||
| 177 | }, | ||
| 178 | }, | ||
| 179 | 2, | ||
| 180 | ); | ||
| 181 | |||
| 182 | expect(migrated).toEqual({ | ||
| 183 | terminalUiStateByThreadKey: { | ||
| 184 | [scopedThreadKey(THREAD_REF)]: { | ||
| 185 | terminalOpen: true, | ||
| 186 | terminalHeight: 320, | ||
| 187 | terminalIds: ["term-1"], | ||
| 188 | activeTerminalId: "term-1", | ||
| 189 | terminalGroups: [{ id: "group-term-1", terminalIds: ["term-1"] }], | ||
| 190 | activeTerminalGroupId: "group-term-1", | ||
| 191 | }, | ||
| 192 | }, | ||
| 193 | }); | ||
| 194 | }); | ||
| 195 | |||
| 196 | it("resets to default and clears persisted entry when closing the last terminal", () => { | ||
| 197 | const store = useTerminalUiStateStore.getState(); | ||
| 198 | store.newTerminal(THREAD_REF, "terminal-only"); | ||
| 199 | store.closeTerminal(THREAD_REF, "terminal-only"); | ||
| 200 | |||
| 201 | expect( | ||
| 202 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey[scopedThreadKey(THREAD_REF)], | ||
| 203 | ).toBeUndefined(); | ||
| 204 | expect( | ||
| 205 | selectThreadTerminalUiState( | ||
| 206 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 207 | THREAD_REF, | ||
| 208 | ).terminalIds, | ||
| 209 | ).toEqual([]); | ||
| 210 | }); | ||
| 211 | |||
| 212 | it("keeps a valid active terminal after closing an active split terminal", () => { | ||
| 213 | const store = useTerminalUiStateStore.getState(); | ||
| 214 | store.splitTerminal(THREAD_REF, "terminal-2"); | ||
| 215 | store.splitTerminal(THREAD_REF, "terminal-3"); | ||
| 216 | store.closeTerminal(THREAD_REF, "terminal-3"); | ||
| 217 | |||
| 218 | const terminalUiState = selectThreadTerminalUiState( | ||
| 219 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 220 | THREAD_REF, | ||
| 221 | ); | ||
| 222 | expect(terminalUiState.activeTerminalId).toBe("terminal-2"); | ||
| 223 | expect(terminalUiState.terminalIds).toEqual(["terminal-2"]); | ||
| 224 | expect(terminalUiState.terminalGroups).toEqual([ | ||
| 225 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, | ||
| 226 | ]); | ||
| 227 | }); | ||
| 228 | |||
| 229 | it("reconciles terminal ids from an external ordered list", () => { | ||
| 230 | const store = useTerminalUiStateStore.getState(); | ||
| 231 | store.setTerminalOpen(THREAD_REF, true); | ||
| 232 | store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]); | ||
| 233 | |||
| 234 | const terminalUiState = selectThreadTerminalUiState( | ||
| 235 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 236 | THREAD_REF, | ||
| 237 | ); | ||
| 238 | expect(terminalUiState.terminalIds).toEqual(["term-a", "term-b"]); | ||
| 239 | expect(terminalUiState.activeTerminalId).toBe("term-a"); | ||
| 240 | expect(terminalUiState.terminalGroups).toEqual([ | ||
| 241 | { id: "group-term-a", terminalIds: ["term-a"] }, | ||
| 242 | { id: "group-term-b", terminalIds: ["term-b"] }, | ||
| 243 | ]); | ||
| 244 | }); | ||
| 245 | |||
| 246 | it("is a no-op when clearing terminal UI state for a thread with no state", () => { | ||
| 247 | const store = useTerminalUiStateStore.getState(); | ||
| 248 | const before = useTerminalUiStateStore.getState(); | ||
| 249 | |||
| 250 | store.clearTerminalUiState(THREAD_REF); | ||
| 251 | |||
| 252 | expect(useTerminalUiStateStore.getState()).toBe(before); | ||
| 253 | }); | ||
| 254 | }); | ||
apps/web/src/terminalUiStateStore.ts created+641| ... | @@ -0,0 +1,641 @@ | ||
| 1 | /** | ||
| 2 | * Single Zustand store for terminal UI state keyed by scoped thread identity. | ||
| 3 | * | ||
| 4 | * Terminal UI transition helpers are intentionally private to keep the public | ||
| 5 | * API constrained to store actions/selectors. | ||
| 6 | */ | ||
| 7 | |||
| 8 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; | ||
| 9 | import { type ScopedThreadRef } from "@t3tools/contracts"; | ||
| 10 | import { create } from "zustand"; | ||
| 11 | import { createJSONStorage, persist } from "zustand/middleware"; | ||
| 12 | import { resolveStorage } from "./lib/storage"; | ||
| 13 | import { | ||
| 14 | DEFAULT_THREAD_TERMINAL_HEIGHT, | ||
| 15 | DEFAULT_THREAD_TERMINAL_ID, | ||
| 16 | MAX_TERMINALS_PER_GROUP, | ||
| 17 | type ThreadTerminalGroup, | ||
| 18 | } from "./types"; | ||
| 19 | |||
| 20 | interface ThreadTerminalUiState { | ||
| 21 | terminalOpen: boolean; | ||
| 22 | terminalHeight: number; | ||
| 23 | terminalIds: string[]; | ||
| 24 | activeTerminalId: string; | ||
| 25 | terminalGroups: ThreadTerminalGroup[]; | ||
| 26 | activeTerminalGroupId: string; | ||
| 27 | } | ||
| 28 | |||
| 29 | // Keep the old storage key so existing drawer layout preferences migrate. | ||
| 30 | const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; | ||
| 31 | |||
| 32 | interface PersistedTerminalUiStateStoreState { | ||
| 33 | terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>; | ||
| 34 | terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>; | ||
| 35 | } | ||
| 36 | |||
| 37 | export function migratePersistedTerminalUiStateStoreState( | ||
| 38 | persistedState: unknown, | ||
| 39 | _version: number, | ||
| 40 | ): PersistedTerminalUiStateStoreState { | ||
| 41 | if (!persistedState || typeof persistedState !== "object") { | ||
| 42 | return { terminalUiStateByThreadKey: {} }; | ||
| 43 | } | ||
| 44 | |||
| 45 | const candidate = persistedState as PersistedTerminalUiStateStoreState; | ||
| 46 | const persistedUiStateByThreadKey = | ||
| 47 | candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {}; | ||
| 48 | const terminalUiStateByThreadKey = Object.fromEntries( | ||
| 49 | Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) => | ||
| 50 | parseScopedThreadKey(threadKey), | ||
| 51 | ), | ||
| 52 | ); | ||
| 53 | |||
| 54 | return { terminalUiStateByThreadKey }; | ||
| 55 | } | ||
| 56 | |||
| 57 | function createTerminalUiStateStorage() { | ||
| 58 | return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); | ||
| 59 | } | ||
| 60 | |||
| 61 | function normalizeTerminalIds(terminalIds: string[]): string[] { | ||
| 62 | const normalizedIds: string[] = []; | ||
| 63 | const seen = new Set<string>(); | ||
| 64 | for (const id of terminalIds) { | ||
| 65 | const trimmedId = id.trim(); | ||
| 66 | if (trimmedId.length === 0 || seen.has(trimmedId)) continue; | ||
| 67 | seen.add(trimmedId); | ||
| 68 | normalizedIds.push(trimmedId); | ||
| 69 | } | ||
| 70 | return normalizedIds; | ||
| 71 | } | ||
| 72 | |||
| 73 | function fallbackGroupId(terminalId: string): string { | ||
| 74 | return `group-${terminalId}`; | ||
| 75 | } | ||
| 76 | |||
| 77 | function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string { | ||
| 78 | let candidate = baseId; | ||
| 79 | let index = 2; | ||
| 80 | while (usedGroupIds.has(candidate)) { | ||
| 81 | candidate = `${baseId}-${index}`; | ||
| 82 | index += 1; | ||
| 83 | } | ||
| 84 | usedGroupIds.add(candidate); | ||
| 85 | return candidate; | ||
| 86 | } | ||
| 87 | |||
| 88 | function findGroupIndexByTerminalId( | ||
| 89 | terminalGroups: ThreadTerminalGroup[], | ||
| 90 | terminalId: string, | ||
| 91 | ): number { | ||
| 92 | return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); | ||
| 93 | } | ||
| 94 | |||
| 95 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { | ||
| 96 | return normalizeTerminalIds(terminalIds); | ||
| 97 | } | ||
| 98 | |||
| 99 | function normalizeTerminalGroups( | ||
| 100 | terminalGroups: ThreadTerminalGroup[], | ||
| 101 | terminalIds: string[], | ||
| 102 | ): ThreadTerminalGroup[] { | ||
| 103 | if (terminalIds.length === 0) { | ||
| 104 | return []; | ||
| 105 | } | ||
| 106 | |||
| 107 | const validTerminalIdSet = new Set(terminalIds); | ||
| 108 | const assignedTerminalIds = new Set<string>(); | ||
| 109 | const nextGroups: ThreadTerminalGroup[] = []; | ||
| 110 | const usedGroupIds = new Set<string>(); | ||
| 111 | |||
| 112 | for (const group of terminalGroups) { | ||
| 113 | const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { | ||
| 114 | if (!validTerminalIdSet.has(terminalId)) return false; | ||
| 115 | if (assignedTerminalIds.has(terminalId)) return false; | ||
| 116 | return true; | ||
| 117 | }); | ||
| 118 | if (groupTerminalIds.length === 0) continue; | ||
| 119 | for (const terminalId of groupTerminalIds) { | ||
| 120 | assignedTerminalIds.add(terminalId); | ||
| 121 | } | ||
| 122 | const baseGroupId = | ||
| 123 | group.id.trim().length > 0 | ||
| 124 | ? group.id.trim() | ||
| 125 | : fallbackGroupId(groupTerminalIds[0] ?? terminalIds[0] ?? ""); | ||
| 126 | nextGroups.push({ | ||
| 127 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), | ||
| 128 | terminalIds: groupTerminalIds, | ||
| 129 | }); | ||
| 130 | } | ||
| 131 | |||
| 132 | for (const terminalId of terminalIds) { | ||
| 133 | if (assignedTerminalIds.has(terminalId)) continue; | ||
| 134 | nextGroups.push({ | ||
| 135 | id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), | ||
| 136 | terminalIds: [terminalId], | ||
| 137 | }); | ||
| 138 | } | ||
| 139 | |||
| 140 | return nextGroups; | ||
| 141 | } | ||
| 142 | |||
| 143 | function arraysEqual(a: string[], b: string[]): boolean { | ||
| 144 | if (a.length !== b.length) return false; | ||
| 145 | for (let index = 0; index < a.length; index += 1) { | ||
| 146 | if (a[index] !== b[index]) return false; | ||
| 147 | } | ||
| 148 | return true; | ||
| 149 | } | ||
| 150 | |||
| 151 | function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean { | ||
| 152 | if (left.length !== right.length) return false; | ||
| 153 | for (let index = 0; index < left.length; index += 1) { | ||
| 154 | const leftGroup = left[index]; | ||
| 155 | const rightGroup = right[index]; | ||
| 156 | if (!leftGroup || !rightGroup) return false; | ||
| 157 | if (leftGroup.id !== rightGroup.id) return false; | ||
| 158 | if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; | ||
| 159 | } | ||
| 160 | return true; | ||
| 161 | } | ||
| 162 | |||
| 163 | function threadTerminalUiStateEqual( | ||
| 164 | left: ThreadTerminalUiState, | ||
| 165 | right: ThreadTerminalUiState, | ||
| 166 | ): boolean { | ||
| 167 | return ( | ||
| 168 | left.terminalOpen === right.terminalOpen && | ||
| 169 | left.terminalHeight === right.terminalHeight && | ||
| 170 | left.activeTerminalId === right.activeTerminalId && | ||
| 171 | left.activeTerminalGroupId === right.activeTerminalGroupId && | ||
| 172 | arraysEqual(left.terminalIds, right.terminalIds) && | ||
| 173 | terminalGroupsEqual(left.terminalGroups, right.terminalGroups) | ||
| 174 | ); | ||
| 175 | } | ||
| 176 | |||
| 177 | const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({ | ||
| 178 | terminalOpen: false, | ||
| 179 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | ||
| 180 | terminalIds: [], | ||
| 181 | activeTerminalId: "", | ||
| 182 | terminalGroups: [], | ||
| 183 | activeTerminalGroupId: "", | ||
| 184 | }); | ||
| 185 | |||
| 186 | function createDefaultThreadTerminalUiState(): ThreadTerminalUiState { | ||
| 187 | return { | ||
| 188 | ...DEFAULT_THREAD_TERMINAL_UI_STATE, | ||
| 189 | terminalIds: [...DEFAULT_THREAD_TERMINAL_UI_STATE.terminalIds], | ||
| 190 | terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_UI_STATE.terminalGroups), | ||
| 191 | }; | ||
| 192 | } | ||
| 193 | |||
| 194 | function getDefaultThreadTerminalUiState(): ThreadTerminalUiState { | ||
| 195 | return DEFAULT_THREAD_TERMINAL_UI_STATE; | ||
| 196 | } | ||
| 197 | |||
| 198 | function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState { | ||
| 199 | const nextTerminalIds = normalizeTerminalIds(state.terminalIds); | ||
| 200 | const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) | ||
| 201 | ? state.activeTerminalId | ||
| 202 | : (nextTerminalIds[0] ?? ""); | ||
| 203 | const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); | ||
| 204 | const activeGroupIdFromState = terminalGroups.some( | ||
| 205 | (group) => group.id === state.activeTerminalGroupId, | ||
| 206 | ) | ||
| 207 | ? state.activeTerminalGroupId | ||
| 208 | : null; | ||
| 209 | const activeGroupIdFromTerminal = | ||
| 210 | terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; | ||
| 211 | |||
| 212 | const normalized: ThreadTerminalUiState = { | ||
| 213 | terminalOpen: state.terminalOpen, | ||
| 214 | terminalHeight: | ||
| 215 | Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 | ||
| 216 | ? state.terminalHeight | ||
| 217 | : DEFAULT_THREAD_TERMINAL_HEIGHT, | ||
| 218 | terminalIds: nextTerminalIds, | ||
| 219 | activeTerminalId, | ||
| 220 | terminalGroups, | ||
| 221 | activeTerminalGroupId: | ||
| 222 | activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", | ||
| 223 | }; | ||
| 224 | return threadTerminalUiStateEqual(state, normalized) ? state : normalized; | ||
| 225 | } | ||
| 226 | |||
| 227 | function isDefaultThreadTerminalUiState(state: ThreadTerminalUiState): boolean { | ||
| 228 | const normalized = normalizeThreadTerminalUiState(state); | ||
| 229 | return threadTerminalUiStateEqual(normalized, DEFAULT_THREAD_TERMINAL_UI_STATE); | ||
| 230 | } | ||
| 231 | |||
| 232 | function isValidTerminalId(terminalId: string): boolean { | ||
| 233 | return terminalId.trim().length > 0; | ||
| 234 | } | ||
| 235 | |||
| 236 | function terminalThreadKey(threadRef: ScopedThreadRef): string { | ||
| 237 | return scopedThreadKey(threadRef); | ||
| 238 | } | ||
| 239 | |||
| 240 | function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { | ||
| 241 | return groups.map((group) => ({ | ||
| 242 | id: group.id, | ||
| 243 | terminalIds: [...group.terminalIds], | ||
| 244 | })); | ||
| 245 | } | ||
| 246 | |||
| 247 | function upsertTerminalIntoGroups( | ||
| 248 | state: ThreadTerminalUiState, | ||
| 249 | terminalId: string, | ||
| 250 | mode: "split" | "new", | ||
| 251 | ): ThreadTerminalUiState { | ||
| 252 | const normalized = normalizeThreadTerminalUiState(state); | ||
| 253 | const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; | ||
| 254 | if (!isValidTerminalId(terminalId)) { | ||
| 255 | return normalized; | ||
| 256 | } | ||
| 257 | |||
| 258 | const isNewTerminal = !normalized.terminalIds.includes(terminalId); | ||
| 259 | const terminalIds = isNewTerminal | ||
| 260 | ? [...normalized.terminalIds, terminalId] | ||
| 261 | : normalized.terminalIds; | ||
| 262 | const terminalGroups = copyTerminalGroups(normalized.terminalGroups); | ||
| 263 | |||
| 264 | const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); | ||
| 265 | if (existingGroupIndex >= 0) { | ||
| 266 | terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ | ||
| 267 | existingGroupIndex | ||
| 268 | ]!.terminalIds.filter((id) => id !== terminalId); | ||
| 269 | if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { | ||
| 270 | terminalGroups.splice(existingGroupIndex, 1); | ||
| 271 | } | ||
| 272 | } | ||
| 273 | |||
| 274 | if (effectiveMode === "new") { | ||
| 275 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | ||
| 276 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); | ||
| 277 | terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); | ||
| 278 | return normalizeThreadTerminalUiState({ | ||
| 279 | ...normalized, | ||
| 280 | terminalOpen: true, | ||
| 281 | terminalIds, | ||
| 282 | activeTerminalId: terminalId, | ||
| 283 | terminalGroups, | ||
| 284 | activeTerminalGroupId: nextGroupId, | ||
| 285 | }); | ||
| 286 | } | ||
| 287 | |||
| 288 | let activeGroupIndex = terminalGroups.findIndex( | ||
| 289 | (group) => group.id === normalized.activeTerminalGroupId, | ||
| 290 | ); | ||
| 291 | if (activeGroupIndex < 0) { | ||
| 292 | activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); | ||
| 293 | } | ||
| 294 | if (activeGroupIndex < 0) { | ||
| 295 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | ||
| 296 | const nextGroupId = assignUniqueGroupId( | ||
| 297 | fallbackGroupId(normalized.activeTerminalId), | ||
| 298 | usedGroupIds, | ||
| 299 | ); | ||
| 300 | terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); | ||
| 301 | activeGroupIndex = terminalGroups.length - 1; | ||
| 302 | } | ||
| 303 | |||
| 304 | const destinationGroup = terminalGroups[activeGroupIndex]; | ||
| 305 | if (!destinationGroup) { | ||
| 306 | return normalized; | ||
| 307 | } | ||
| 308 | const destinationTerminalIdSet = new Set(destinationGroup.terminalIds); | ||
| 309 | |||
| 310 | if ( | ||
| 311 | isNewTerminal && | ||
| 312 | !destinationTerminalIdSet.has(terminalId) && | ||
| 313 | destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP | ||
| 314 | ) { | ||
| 315 | return normalized; | ||
| 316 | } | ||
| 317 | |||
| 318 | if (!destinationTerminalIdSet.has(terminalId)) { | ||
| 319 | const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); | ||
| 320 | if (anchorIndex >= 0) { | ||
| 321 | destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); | ||
| 322 | } else { | ||
| 323 | destinationGroup.terminalIds.push(terminalId); | ||
| 324 | } | ||
| 325 | } | ||
| 326 | |||
| 327 | return normalizeThreadTerminalUiState({ | ||
| 328 | ...normalized, | ||
| 329 | terminalOpen: true, | ||
| 330 | terminalIds, | ||
| 331 | activeTerminalId: terminalId, | ||
| 332 | terminalGroups, | ||
| 333 | activeTerminalGroupId: destinationGroup.id, | ||
| 334 | }); | ||
| 335 | } | ||
| 336 | |||
| 337 | function setThreadTerminalOpen(state: ThreadTerminalUiState, open: boolean): ThreadTerminalUiState { | ||
| 338 | const normalized = normalizeThreadTerminalUiState(state); | ||
| 339 | if (open && normalized.terminalIds.length === 0) { | ||
| 340 | return upsertTerminalIntoGroups(normalized, DEFAULT_THREAD_TERMINAL_ID, "new"); | ||
| 341 | } | ||
| 342 | if (normalized.terminalOpen === open) return normalized; | ||
| 343 | return { ...normalized, terminalOpen: open }; | ||
| 344 | } | ||
| 345 | |||
| 346 | function setThreadTerminalHeight( | ||
| 347 | state: ThreadTerminalUiState, | ||
| 348 | height: number, | ||
| 349 | ): ThreadTerminalUiState { | ||
| 350 | const normalized = normalizeThreadTerminalUiState(state); | ||
| 351 | if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { | ||
| 352 | return normalized; | ||
| 353 | } | ||
| 354 | return { ...normalized, terminalHeight: height }; | ||
| 355 | } | ||
| 356 | |||
| 357 | function splitThreadTerminal( | ||
| 358 | state: ThreadTerminalUiState, | ||
| 359 | terminalId: string, | ||
| 360 | ): ThreadTerminalUiState { | ||
| 361 | return upsertTerminalIntoGroups(state, terminalId, "split"); | ||
| 362 | } | ||
| 363 | |||
| 364 | function newThreadTerminal( | ||
| 365 | state: ThreadTerminalUiState, | ||
| 366 | terminalId: string, | ||
| 367 | ): ThreadTerminalUiState { | ||
| 368 | return upsertTerminalIntoGroups(state, terminalId, "new"); | ||
| 369 | } | ||
| 370 | |||
| 371 | function setThreadActiveTerminal( | ||
| 372 | state: ThreadTerminalUiState, | ||
| 373 | terminalId: string, | ||
| 374 | ): ThreadTerminalUiState { | ||
| 375 | const normalized = normalizeThreadTerminalUiState(state); | ||
| 376 | if (!normalized.terminalIds.includes(terminalId)) { | ||
| 377 | return normalized; | ||
| 378 | } | ||
| 379 | const activeTerminalGroupId = | ||
| 380 | normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ?? | ||
| 381 | normalized.activeTerminalGroupId; | ||
| 382 | if ( | ||
| 383 | normalized.activeTerminalId === terminalId && | ||
| 384 | normalized.activeTerminalGroupId === activeTerminalGroupId | ||
| 385 | ) { | ||
| 386 | return normalized; | ||
| 387 | } | ||
| 388 | return { | ||
| 389 | ...normalized, | ||
| 390 | activeTerminalId: terminalId, | ||
| 391 | activeTerminalGroupId, | ||
| 392 | }; | ||
| 393 | } | ||
| 394 | |||
| 395 | function closeThreadTerminal( | ||
| 396 | state: ThreadTerminalUiState, | ||
| 397 | terminalId: string, | ||
| 398 | ): ThreadTerminalUiState { | ||
| 399 | const normalized = normalizeThreadTerminalUiState(state); | ||
| 400 | if (!normalized.terminalIds.includes(terminalId)) { | ||
| 401 | return normalized; | ||
| 402 | } | ||
| 403 | |||
| 404 | const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); | ||
| 405 | if (remainingTerminalIds.length === 0) { | ||
| 406 | return createDefaultThreadTerminalUiState(); | ||
| 407 | } | ||
| 408 | |||
| 409 | const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); | ||
| 410 | const nextActiveTerminalId = | ||
| 411 | normalized.activeTerminalId === terminalId | ||
| 412 | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? | ||
| 413 | remainingTerminalIds[0] ?? | ||
| 414 | "") | ||
| 415 | : normalized.activeTerminalId; | ||
| 416 | |||
| 417 | const terminalGroups: ThreadTerminalGroup[] = []; | ||
| 418 | for (const group of normalized.terminalGroups) { | ||
| 419 | const terminalIds = group.terminalIds.filter((id) => id !== terminalId); | ||
| 420 | if (terminalIds.length > 0) { | ||
| 421 | terminalGroups.push({ ...group, terminalIds }); | ||
| 422 | } | ||
| 423 | } | ||
| 424 | |||
| 425 | const nextActiveTerminalGroupId = | ||
| 426 | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? | ||
| 427 | terminalGroups[0]?.id ?? | ||
| 428 | fallbackGroupId(nextActiveTerminalId); | ||
| 429 | |||
| 430 | return normalizeThreadTerminalUiState({ | ||
| 431 | terminalOpen: normalized.terminalOpen, | ||
| 432 | terminalHeight: normalized.terminalHeight, | ||
| 433 | terminalIds: remainingTerminalIds, | ||
| 434 | activeTerminalId: nextActiveTerminalId, | ||
| 435 | terminalGroups, | ||
| 436 | activeTerminalGroupId: nextActiveTerminalGroupId, | ||
| 437 | }); | ||
| 438 | } | ||
| 439 | |||
| 440 | function reconcileThreadTerminalSessionIds( | ||
| 441 | state: ThreadTerminalUiState, | ||
| 442 | nextIds: string[], | ||
| 443 | ): ThreadTerminalUiState { | ||
| 444 | const normalized = normalizeThreadTerminalUiState(state); | ||
| 445 | if (arraysEqual(normalized.terminalIds, nextIds)) { | ||
| 446 | return normalized; | ||
| 447 | } | ||
| 448 | |||
| 449 | const nextActiveTerminalId = nextIds.includes(normalized.activeTerminalId) | ||
| 450 | ? normalized.activeTerminalId | ||
| 451 | : (nextIds[0] ?? ""); | ||
| 452 | |||
| 453 | const terminalGroups = normalizeTerminalGroups(normalized.terminalGroups, nextIds); | ||
| 454 | const activeGroupIdFromTerminal = | ||
| 455 | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? null; | ||
| 456 | |||
| 457 | return normalizeThreadTerminalUiState({ | ||
| 458 | ...normalized, | ||
| 459 | terminalIds: nextIds, | ||
| 460 | activeTerminalId: nextActiveTerminalId, | ||
| 461 | terminalGroups, | ||
| 462 | activeTerminalGroupId: activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "", | ||
| 463 | }); | ||
| 464 | } | ||
| 465 | |||
| 466 | export function selectThreadTerminalUiState( | ||
| 467 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, | ||
| 468 | threadRef: ScopedThreadRef | null | undefined, | ||
| 469 | ): ThreadTerminalUiState { | ||
| 470 | if (!threadRef || threadRef.threadId.length === 0) { | ||
| 471 | return getDefaultThreadTerminalUiState(); | ||
| 472 | } | ||
| 473 | return ( | ||
| 474 | terminalUiStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalUiState() | ||
| 475 | ); | ||
| 476 | } | ||
| 477 | |||
| 478 | function updateTerminalUiStateByThreadKey( | ||
| 479 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>, | ||
| 480 | threadRef: ScopedThreadRef, | ||
| 481 | updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState, | ||
| 482 | ): Record<string, ThreadTerminalUiState> { | ||
| 483 | if (threadRef.threadId.length === 0) { | ||
| 484 | return terminalUiStateByThreadKey; | ||
| 485 | } | ||
| 486 | |||
| 487 | const threadKey = terminalThreadKey(threadRef); | ||
| 488 | const current = selectThreadTerminalUiState(terminalUiStateByThreadKey, threadRef); | ||
| 489 | const next = updater(current); | ||
| 490 | if (next === current) { | ||
| 491 | return terminalUiStateByThreadKey; | ||
| 492 | } | ||
| 493 | |||
| 494 | if (isDefaultThreadTerminalUiState(next)) { | ||
| 495 | if (terminalUiStateByThreadKey[threadKey] === undefined) { | ||
| 496 | return terminalUiStateByThreadKey; | ||
| 497 | } | ||
| 498 | const { [threadKey]: _removed, ...rest } = terminalUiStateByThreadKey; | ||
| 499 | return rest; | ||
| 500 | } | ||
| 501 | |||
| 502 | return { | ||
| 503 | ...terminalUiStateByThreadKey, | ||
| 504 | [threadKey]: next, | ||
| 505 | }; | ||
| 506 | } | ||
| 507 | |||
| 508 | interface TerminalUiStateStoreState { | ||
| 509 | terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>; | ||
| 510 | setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; | ||
| 511 | setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; | ||
| 512 | splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | ||
| 513 | newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | ||
| 514 | ensureTerminal: ( | ||
| 515 | threadRef: ScopedThreadRef, | ||
| 516 | terminalId: string, | ||
| 517 | options?: { open?: boolean; active?: boolean }, | ||
| 518 | ) => void; | ||
| 519 | setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | ||
| 520 | closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; | ||
| 521 | reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; | ||
| 522 | clearTerminalUiState: (threadRef: ScopedThreadRef) => void; | ||
| 523 | removeTerminalUiState: (threadRef: ScopedThreadRef) => void; | ||
| 524 | removeOrphanedTerminalUiStates: (activeThreadKeys: Set<string>) => void; | ||
| 525 | } | ||
| 526 | |||
| 527 | export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( | ||
| 528 | persist( | ||
| 529 | (set) => { | ||
| 530 | const updateTerminal = ( | ||
| 531 | threadRef: ScopedThreadRef, | ||
| 532 | updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState, | ||
| 533 | ) => { | ||
| 534 | set((state) => { | ||
| 535 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( | ||
| 536 | state.terminalUiStateByThreadKey, | ||
| 537 | threadRef, | ||
| 538 | updater, | ||
| 539 | ); | ||
| 540 | if (nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey) { | ||
| 541 | return state; | ||
| 542 | } | ||
| 543 | return { | ||
| 544 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, | ||
| 545 | }; | ||
| 546 | }); | ||
| 547 | }; | ||
| 548 | |||
| 549 | return { | ||
| 550 | terminalUiStateByThreadKey: {}, | ||
| 551 | setTerminalOpen: (threadRef, open) => | ||
| 552 | updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)), | ||
| 553 | setTerminalHeight: (threadRef, height) => | ||
| 554 | updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), | ||
| 555 | splitTerminal: (threadRef, terminalId) => | ||
| 556 | updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), | ||
| 557 | newTerminal: (threadRef, terminalId) => | ||
| 558 | updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), | ||
| 559 | ensureTerminal: (threadRef, terminalId, options) => | ||
| 560 | updateTerminal(threadRef, (state) => { | ||
| 561 | let nextState = state; | ||
| 562 | if (!state.terminalIds.includes(terminalId)) { | ||
| 563 | nextState = newThreadTerminal(nextState, terminalId); | ||
| 564 | } | ||
| 565 | if (options?.active === false) { | ||
| 566 | nextState = { | ||
| 567 | ...nextState, | ||
| 568 | activeTerminalId: state.activeTerminalId, | ||
| 569 | activeTerminalGroupId: state.activeTerminalGroupId, | ||
| 570 | }; | ||
| 571 | } | ||
| 572 | if (options?.active ?? true) { | ||
| 573 | nextState = setThreadActiveTerminal(nextState, terminalId); | ||
| 574 | } | ||
| 575 | if (options?.open) { | ||
| 576 | nextState = setThreadTerminalOpen(nextState, true); | ||
| 577 | } | ||
| 578 | return normalizeThreadTerminalUiState(nextState); | ||
| 579 | }), | ||
| 580 | setActiveTerminal: (threadRef, terminalId) => | ||
| 581 | updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), | ||
| 582 | closeTerminal: (threadRef, terminalId) => | ||
| 583 | updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), | ||
| 584 | reconcileTerminalIds: (threadRef, nextIds) => | ||
| 585 | updateTerminal(threadRef, (state) => reconcileThreadTerminalSessionIds(state, nextIds)), | ||
| 586 | clearTerminalUiState: (threadRef) => | ||
| 587 | set((state) => { | ||
| 588 | const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey( | ||
| 589 | state.terminalUiStateByThreadKey, | ||
| 590 | threadRef, | ||
| 591 | () => createDefaultThreadTerminalUiState(), | ||
| 592 | ); | ||
| 593 | if (nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey) { | ||
| 594 | return state; | ||
| 595 | } | ||
| 596 | return { | ||
| 597 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, | ||
| 598 | }; | ||
| 599 | }), | ||
| 600 | removeTerminalUiState: (threadRef) => | ||
| 601 | set((state) => { | ||
| 602 | const threadKey = terminalThreadKey(threadRef); | ||
| 603 | const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; | ||
| 604 | if (!hadTerminalUiState) { | ||
| 605 | return state; | ||
| 606 | } | ||
| 607 | const nextTerminalUiStateByThreadKey = { ...state.terminalUiStateByThreadKey }; | ||
| 608 | delete nextTerminalUiStateByThreadKey[threadKey]; | ||
| 609 | return { | ||
| 610 | terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, | ||
| 611 | }; | ||
| 612 | }), | ||
| 613 | removeOrphanedTerminalUiStates: (activeThreadKeys) => | ||
| 614 | set((state) => { | ||
| 615 | const orphanedIds = Object.keys(state.terminalUiStateByThreadKey).filter( | ||
| 616 | (key) => !activeThreadKeys.has(key), | ||
| 617 | ); | ||
| 618 | if (orphanedIds.length === 0) { | ||
| 619 | return state; | ||
| 620 | } | ||
| 621 | const next = { ...state.terminalUiStateByThreadKey }; | ||
| 622 | for (const id of orphanedIds) { | ||
| 623 | delete next[id]; | ||
| 624 | } | ||
| 625 | return { | ||
| 626 | terminalUiStateByThreadKey: next, | ||
| 627 | }; | ||
| 628 | }), | ||
| 629 | }; | ||
| 630 | }, | ||
| 631 | { | ||
| 632 | name: TERMINAL_UI_STATE_STORAGE_KEY, | ||
| 633 | version: 4, | ||
| 634 | storage: createJSONStorage(createTerminalUiStateStorage), | ||
| 635 | migrate: migratePersistedTerminalUiStateStoreState, | ||
| 636 | partialize: (state) => ({ | ||
| 637 | terminalUiStateByThreadKey: state.terminalUiStateByThreadKey, | ||
| 638 | }), | ||
| 639 | }, | ||
| 640 | ), | ||
| 641 | ); | ||
apps/web/src/types.ts+1-1| ... | @@ -23,7 +23,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | ... | @@ -23,7 +23,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 23 | 23 | ||
| 24 | export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default"; | 24 | export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default"; |
| 25 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; | 25 | export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; |
| 26 | export const DEFAULT_THREAD_TERMINAL_ID = "default"; | 26 | export const DEFAULT_THREAD_TERMINAL_ID = "term-1"; |
| 27 | export const MAX_TERMINALS_PER_GROUP = 4; | 27 | export const MAX_TERMINALS_PER_GROUP = 4; |
| 28 | export type ProjectScript = ContractProjectScript; | 28 | export type ProjectScript = ContractProjectScript; |
| 29 | 29 |
apps/web/test/wsRpcHarness.ts+3| ... | @@ -30,10 +30,13 @@ const STREAM_METHODS = new Set<string>([ | ... | @@ -30,10 +30,13 @@ const STREAM_METHODS = new Set<string>([ |
| 30 | ORCHESTRATION_WS_METHODS.subscribeShell, | 30 | ORCHESTRATION_WS_METHODS.subscribeShell, |
| 31 | ORCHESTRATION_WS_METHODS.subscribeThread, | 31 | ORCHESTRATION_WS_METHODS.subscribeThread, |
| 32 | WS_METHODS.gitRunStackedAction, | 32 | WS_METHODS.gitRunStackedAction, |
| 33 | WS_METHODS.terminalAttach, | ||
| 33 | WS_METHODS.subscribeVcsStatus, | 34 | WS_METHODS.subscribeVcsStatus, |
| 34 | WS_METHODS.subscribeTerminalEvents, | 35 | WS_METHODS.subscribeTerminalEvents, |
| 36 | WS_METHODS.subscribeTerminalMetadata, | ||
| 35 | WS_METHODS.subscribeServerConfig, | 37 | WS_METHODS.subscribeServerConfig, |
| 36 | WS_METHODS.subscribeServerLifecycle, | 38 | WS_METHODS.subscribeServerLifecycle, |
| 39 | WS_METHODS.subscribeAuthAccess, | ||
| 37 | ]); | 40 | ]); |
| 38 | 41 | ||
| 39 | const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys()); | 42 | const ALL_RPC_METHODS = Array.from(WsRpcGroup.requests.keys()); |
apps/web/vitest.config.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { defineConfig, mergeConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | import viteConfig from "./vite.config"; | ||
| 4 | |||
| 5 | export default mergeConfig( | ||
| 6 | viteConfig, | ||
| 7 | defineConfig({ | ||
| 8 | test: { | ||
| 9 | // The web runtime suite exercises auth bootstrap, saved environments, | ||
| 10 | // and websocket subscription lifecycles. Under the full monorepo test | ||
| 11 | // run, those async tests can exceed Vitest's default 5s budget. | ||
| 12 | testTimeout: 15_000, | ||
| 13 | hookTimeout: 15_000, | ||
| 14 | }, | ||
| 15 | }), | ||
| 16 | ); | ||
bun.lock+1187-59| ... | @@ -46,6 +46,74 @@ | ... | @@ -46,6 +46,74 @@ |
| 46 | "typescript": "catalog:", | 46 | "typescript": "catalog:", |
| 47 | }, | 47 | }, |
| 48 | }, | 48 | }, |
| 49 | "apps/mobile": { | ||
| 50 | "name": "@t3tools/mobile", | ||
| 51 | "version": "0.0.0", | ||
| 52 | "dependencies": { | ||
| 53 | "@callstack/liquid-glass": "^0.7.1", | ||
| 54 | "@effect/atom-react": "catalog:", | ||
| 55 | "@expo-google-fonts/dm-sans": "^0.4.2", | ||
| 56 | "@expo/ui": "~56.0.8", | ||
| 57 | "@legendapp/list": "3.0.0-beta.44", | ||
| 58 | "@pierre/diffs": "catalog:", | ||
| 59 | "@react-native-menu/menu": "^2.0.0", | ||
| 60 | "@shikijs/core": "3.23.0", | ||
| 61 | "@shikijs/engine-javascript": "3.23.0", | ||
| 62 | "@shikijs/langs": "3.23.0", | ||
| 63 | "@shikijs/themes": "3.23.0", | ||
| 64 | "@t3tools/client-runtime": "workspace:*", | ||
| 65 | "@t3tools/contracts": "workspace:*", | ||
| 66 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", | ||
| 67 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", | ||
| 68 | "@t3tools/shared": "workspace:*", | ||
| 69 | "clsx": "^2.1.1", | ||
| 70 | "diff": "8.0.3", | ||
| 71 | "effect": "catalog:", | ||
| 72 | "expo": "^56.0.0", | ||
| 73 | "expo-build-properties": "~56.0.15", | ||
| 74 | "expo-camera": "~56.0.7", | ||
| 75 | "expo-clipboard": "~56.0.3", | ||
| 76 | "expo-constants": "~56.0.16", | ||
| 77 | "expo-crypto": "~56.0.4", | ||
| 78 | "expo-dev-client": "~56.0.16", | ||
| 79 | "expo-file-system": "~56.0.7", | ||
| 80 | "expo-font": "~56.0.5", | ||
| 81 | "expo-glass-effect": "~56.0.4", | ||
| 82 | "expo-haptics": "~56.0.3", | ||
| 83 | "expo-image-picker": "~56.0.14", | ||
| 84 | "expo-linking": "~56.0.12", | ||
| 85 | "expo-paste-input": "^0.1.15", | ||
| 86 | "expo-router": "~56.2.7", | ||
| 87 | "expo-secure-store": "~56.0.4", | ||
| 88 | "expo-splash-screen": "~56.0.10", | ||
| 89 | "expo-symbols": "~56.0.5", | ||
| 90 | "expo-updates": "~56.0.17", | ||
| 91 | "punycode": "^2.3.1", | ||
| 92 | "react": "19.2.3", | ||
| 93 | "react-dom": "19.2.3", | ||
| 94 | "react-native": "0.85.3", | ||
| 95 | "react-native-gesture-handler": "~2.31.1", | ||
| 96 | "react-native-image-viewing": "^0.2.2", | ||
| 97 | "react-native-keyboard-controller": "1.21.6", | ||
| 98 | "react-native-nitro-markdown": "^0.5.0", | ||
| 99 | "react-native-nitro-modules": "^0.35.4", | ||
| 100 | "react-native-reanimated": "4.3.1", | ||
| 101 | "react-native-safe-area-context": "~5.7.0", | ||
| 102 | "react-native-screens": "4.25.2", | ||
| 103 | "react-native-shiki-engine": "^0.3.9", | ||
| 104 | "react-native-svg": "15.15.4", | ||
| 105 | "react-native-worklets": "0.8.3", | ||
| 106 | "shiki": "3.23.0", | ||
| 107 | "tailwind-merge": "^3.5.0", | ||
| 108 | "uniwind": "^1.6.2", | ||
| 109 | }, | ||
| 110 | "devDependencies": { | ||
| 111 | "@types/react": "~19.2.0", | ||
| 112 | "babel-preset-expo": "~56.0.0", | ||
| 113 | "tailwindcss": "^4.0.0", | ||
| 114 | "typescript": "catalog:", | ||
| 115 | }, | ||
| 116 | }, | ||
| 49 | "apps/server": { | 117 | "apps/server": { |
| 50 | "name": "t3", | 118 | "name": "t3", |
| 51 | "version": "0.0.24", | 119 | "version": "0.0.24", |
| ... | @@ -150,6 +218,7 @@ | ... | @@ -150,6 +218,7 @@ |
| 150 | "effect": "catalog:", | 218 | "effect": "catalog:", |
| 151 | }, | 219 | }, |
| 152 | "devDependencies": { | 220 | "devDependencies": { |
| 221 | "@effect/vitest": "catalog:", | ||
| 153 | "vitest": "catalog:", | 222 | "vitest": "catalog:", |
| 154 | }, | 223 | }, |
| 155 | }, | 224 | }, |
| ... | @@ -251,6 +320,7 @@ | ... | @@ -251,6 +320,7 @@ |
| 251 | ], | 320 | ], |
| 252 | "patchedDependencies": { | 321 | "patchedDependencies": { |
| 253 | "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch", | 322 | "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch", |
| 323 | "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch", | ||
| 254 | }, | 324 | }, |
| 255 | "overrides": { | 325 | "overrides": { |
| 256 | "@effect/atom-react": "catalog:", | 326 | "@effect/atom-react": "catalog:", |
| ... | @@ -281,6 +351,8 @@ | ... | @@ -281,6 +351,8 @@ |
| 281 | "vitest": "^4.0.0", | 351 | "vitest": "^4.0.0", |
| 282 | }, | 352 | }, |
| 283 | "packages": { | 353 | "packages": { |
| 354 | "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], | ||
| 355 | |||
| 284 | "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.111", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-DwXyJpVL8JXB8L2toSw1by7uIt1p8hPGi0P+hqr5tL+Ae7DcK9O3tUd6XcGown3LZ49zNCUAIpqX3wDmOhqp0Q=="], | 356 | "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.111", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-DwXyJpVL8JXB8L2toSw1by7uIt1p8hPGi0P+hqr5tL+Ae7DcK9O3tUd6XcGown3LZ49zNCUAIpqX3wDmOhqp0Q=="], |
| 285 | 357 | ||
| 286 | "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="], | 358 | "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="], |
| ... | @@ -323,33 +395,137 @@ | ... | @@ -323,33 +395,137 @@ |
| 323 | 395 | ||
| 324 | "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], | 396 | "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], |
| 325 | 397 | ||
| 326 | "@babel/generator": ["@babel/generator@8.0.0-rc.2", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.2", "@babel/types": "^8.0.0-rc.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ=="], | 398 | "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], |
| 399 | |||
| 400 | "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], | ||
| 327 | 401 | ||
| 328 | "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], | 402 | "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], |
| 329 | 403 | ||
| 404 | "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.3", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.29.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA=="], | ||
| 405 | |||
| 406 | "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.28.5", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw=="], | ||
| 407 | |||
| 408 | "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="], | ||
| 409 | |||
| 330 | "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], | 410 | "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], |
| 331 | 411 | ||
| 412 | "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], | ||
| 413 | |||
| 332 | "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], | 414 | "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], |
| 333 | 415 | ||
| 334 | "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], | 416 | "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], |
| 335 | 417 | ||
| 418 | "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], | ||
| 419 | |||
| 336 | "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], | 420 | "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], |
| 337 | 421 | ||
| 422 | "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-wrap-function": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA=="], | ||
| 423 | |||
| 424 | "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], | ||
| 425 | |||
| 426 | "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], | ||
| 427 | |||
| 338 | "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], | 428 | "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], |
| 339 | 429 | ||
| 340 | "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.2", "", {}, "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A=="], | 430 | "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.2", "", {}, "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A=="], |
| 341 | 431 | ||
| 342 | "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], | 432 | "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], |
| 343 | 433 | ||
| 434 | "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ=="], | ||
| 435 | |||
| 344 | "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], | 436 | "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], |
| 345 | 437 | ||
| 346 | "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], | 438 | "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], |
| 347 | 439 | ||
| 440 | "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.0", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-decorators": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA=="], | ||
| 441 | |||
| 442 | "@babel/plugin-proposal-export-default-from": ["@babel/plugin-proposal-export-default-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw=="], | ||
| 443 | |||
| 444 | "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA=="], | ||
| 445 | |||
| 446 | "@babel/plugin-syntax-dynamic-import": ["@babel/plugin-syntax-dynamic-import@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ=="], | ||
| 447 | |||
| 448 | "@babel/plugin-syntax-export-default-from": ["@babel/plugin-syntax-export-default-from@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ=="], | ||
| 449 | |||
| 450 | "@babel/plugin-syntax-flow": ["@babel/plugin-syntax-flow@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew=="], | ||
| 451 | |||
| 348 | "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], | 452 | "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], |
| 349 | 453 | ||
| 454 | "@babel/plugin-syntax-nullish-coalescing-operator": ["@babel/plugin-syntax-nullish-coalescing-operator@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ=="], | ||
| 455 | |||
| 456 | "@babel/plugin-syntax-optional-chaining": ["@babel/plugin-syntax-optional-chaining@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg=="], | ||
| 457 | |||
| 350 | "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], | 458 | "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], |
| 351 | 459 | ||
| 352 | "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], | 460 | "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA=="], |
| 461 | |||
| 462 | "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1", "@babel/traverse": "^7.29.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w=="], | ||
| 463 | |||
| 464 | "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g=="], | ||
| 465 | |||
| 466 | "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw=="], | ||
| 467 | |||
| 468 | "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="], | ||
| 469 | |||
| 470 | "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.28.6", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ=="], | ||
| 471 | |||
| 472 | "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="], | ||
| 473 | |||
| 474 | "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw=="], | ||
| 475 | |||
| 476 | "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ=="], | ||
| 477 | |||
| 478 | "@babel/plugin-transform-flow-strip-types": ["@babel/plugin-transform-flow-strip-types@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-flow": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg=="], | ||
| 479 | |||
| 480 | "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw=="], | ||
| 481 | |||
| 482 | "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A=="], | ||
| 483 | |||
| 484 | "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], | ||
| 485 | |||
| 486 | "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.0", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ=="], | ||
| 487 | |||
| 488 | "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="], | ||
| 489 | |||
| 490 | "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.28.6", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-transform-destructuring": "^7.28.5", "@babel/plugin-transform-parameters": "^7.27.7", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA=="], | ||
| 491 | |||
| 492 | "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ=="], | ||
| 493 | |||
| 494 | "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="], | ||
| 495 | |||
| 496 | "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.27.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg=="], | ||
| 497 | |||
| 498 | "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.28.6", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg=="], | ||
| 499 | |||
| 500 | "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA=="], | ||
| 501 | |||
| 502 | "@babel/plugin-transform-react-display-name": ["@babel/plugin-transform-react-display-name@7.28.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA=="], | ||
| 503 | |||
| 504 | "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/plugin-syntax-jsx": "^7.28.6", "@babel/types": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow=="], | ||
| 505 | |||
| 506 | "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.27.1", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q=="], | ||
| 507 | |||
| 508 | "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], | ||
| 509 | |||
| 510 | "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], | ||
| 511 | |||
| 512 | "@babel/plugin-transform-react-pure-annotations": ["@babel/plugin-transform-react-pure-annotations@7.27.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA=="], | ||
| 513 | |||
| 514 | "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog=="], | ||
| 515 | |||
| 516 | "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.29.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w=="], | ||
| 517 | |||
| 518 | "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ=="], | ||
| 519 | |||
| 520 | "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg=="], | ||
| 521 | |||
| 522 | "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], | ||
| 523 | |||
| 524 | "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.27.1", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw=="], | ||
| 525 | |||
| 526 | "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], | ||
| 527 | |||
| 528 | "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], | ||
| 353 | 529 | ||
| 354 | "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], | 530 | "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], |
| 355 | 531 | ||
| ... | @@ -363,6 +539,8 @@ | ... | @@ -363,6 +539,8 @@ |
| 363 | 539 | ||
| 364 | "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], | 540 | "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], |
| 365 | 541 | ||
| 542 | "@callstack/liquid-glass": ["@callstack/liquid-glass@0.7.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-N2rzs8g3kneI5G/98AZdVtjc6OUFBBFwPxVGVMoXUEI7QiioB4+aWwbImg5h8Z3Vb/T+fgLYWmmNhi27wHwE4g=="], | ||
| 543 | |||
| 366 | "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="], | 544 | "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="], |
| 367 | 545 | ||
| 368 | "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], | 546 | "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], |
| ... | @@ -409,6 +587,8 @@ | ... | @@ -409,6 +587,8 @@ |
| 409 | 587 | ||
| 410 | "@effect/vitest": ["@effect/vitest@4.0.0-beta.73", "", { "peerDependencies": { "effect": "^4.0.0-beta.73", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-HIZa0aekh0ak1YfzCv8GTN75Vbvxp8g+W+z9KczvtUT+LvitX3i9nhLWZLKRrUP1LaJFLPJS2UG9ZGXjxgbjBg=="], | 588 | "@effect/vitest": ["@effect/vitest@4.0.0-beta.73", "", { "peerDependencies": { "effect": "^4.0.0-beta.73", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-HIZa0aekh0ak1YfzCv8GTN75Vbvxp8g+W+z9KczvtUT+LvitX3i9nhLWZLKRrUP1LaJFLPJS2UG9ZGXjxgbjBg=="], |
| 411 | 589 | ||
| 590 | "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], | ||
| 591 | |||
| 412 | "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], | 592 | "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], |
| 413 | 593 | ||
| 414 | "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], | 594 | "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], |
| ... | @@ -483,6 +663,76 @@ | ... | @@ -483,6 +663,76 @@ |
| 483 | 663 | ||
| 484 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], | 664 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], |
| 485 | 665 | ||
| 666 | "@expo-google-fonts/dm-sans": ["@expo-google-fonts/dm-sans@0.4.2", "", {}, "sha512-e/fLH5aCJzkEenz7axvEn8N7IJAKhUwNjIxS75h9j+Ip1M9S7d22M19gJN3A7QW4egVkBJRyQh1hQFhngQn9yA=="], | ||
| 667 | |||
| 668 | "@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="], | ||
| 669 | |||
| 670 | "@expo/cli": ["@expo/cli@56.1.12", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.10", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.0", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.13", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.12", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.4", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-Ya/13E1yDx1oAuPw5MDmqzIGyzwSs7KSr1EjgSObOF0VO0GD9jqJjvjOiwurjScLUfxcGZQgq23UzMlBVHwdvA=="], | ||
| 671 | |||
| 672 | "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.6", "", { "dependencies": { "node-forge": "^1.3.3" } }, "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w=="], | ||
| 673 | |||
| 674 | "@expo/config": ["@expo/config@56.0.9", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/config-types": "^56.0.5", "@expo/json-file": "^10.2.0", "@expo/require-utils": "^56.1.3", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw=="], | ||
| 675 | |||
| 676 | "@expo/config-plugins": ["@expo/config-plugins@56.0.8", "", { "dependencies": { "@expo/config-types": "^56.0.5", "@expo/json-file": "~10.2.0", "@expo/plist": "^0.7.0", "@expo/require-utils": "^56.1.3", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg=="], | ||
| 677 | |||
| 678 | "@expo/config-types": ["@expo/config-types@56.0.5", "", {}, "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA=="], | ||
| 679 | |||
| 680 | "@expo/devcert": ["@expo/devcert@1.2.1", "", { "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" } }, "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA=="], | ||
| 681 | |||
| 682 | "@expo/devtools": ["@expo/devtools@56.0.2", "", { "dependencies": { "chalk": "^4.1.2" }, "peerDependencies": { "react": "*", "react-native": "*" }, "optionalPeers": ["react", "react-native"] }, "sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ=="], | ||
| 683 | |||
| 684 | "@expo/dom-webview": ["@expo/dom-webview@56.0.5", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew=="], | ||
| 685 | |||
| 686 | "@expo/env": ["@expo/env@2.3.0", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg=="], | ||
| 687 | |||
| 688 | "@expo/expo-modules-macros-plugin": ["@expo/expo-modules-macros-plugin@0.0.9", "", {}, "sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA=="], | ||
| 689 | |||
| 690 | "@expo/fingerprint": ["@expo/fingerprint@0.19.3", "", { "dependencies": { "@expo/env": "^2.3.0", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-w9Au2IVrtc0Ct+WRa05DVHGNHXYq6VyPZWuFP+5x055OeZ5q6k5Yg+aJ1gfShmjdOhDftRcsvmWmTdTZlWaTZw=="], | ||
| 691 | |||
| 692 | "@expo/image-utils": ["@expo/image-utils@0.10.1", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg=="], | ||
| 693 | |||
| 694 | "@expo/inline-modules": ["@expo/inline-modules@0.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8" } }, "sha512-DKEfq877UTAmL/gOT5aFhlLNDg/EVmTSca7JQMKDGR6mjFSAcrmQf4GJNsi6ztiaqj6cTnIfoSz0hAYdnr6RJQ=="], | ||
| 695 | |||
| 696 | "@expo/json-file": ["@expo/json-file@10.2.0", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ=="], | ||
| 697 | |||
| 698 | "@expo/local-build-cache-provider": ["@expo/local-build-cache-provider@56.0.8", "", { "dependencies": { "@expo/config": "~56.0.9", "chalk": "^4.1.2" } }, "sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ=="], | ||
| 699 | |||
| 700 | "@expo/log-box": ["@expo/log-box@56.0.12", "", { "dependencies": { "@expo/dom-webview": "^56.0.5", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-budE6AGmJbpOJfGSOz+JVP3+FevElT82IEIg+ukQ4gZpW/dGO7QX1unFjanKdSaYgudBwJ4FCFGMwWhW/1tXVQ=="], | ||
| 701 | |||
| 702 | "@expo/metro": ["@expo/metro@56.0.0", "", { "dependencies": { "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-minify-terser": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4" } }, "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A=="], | ||
| 703 | |||
| 704 | "@expo/metro-config": ["@expo/metro-config@56.0.13", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@expo/config": "~56.0.9", "@expo/env": "~2.3.0", "@expo/json-file": "~10.2.0", "@expo/metro": "~56.0.0", "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.5.5", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", "getenv": "^2.0.0", "glob": "^13.0.0", "hermes-parser": "^0.33.3", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", "msgpackr": "^2.0.1", "picomatch": "^4.0.4", "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*" }, "optionalPeers": ["expo"] }, "sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA=="], | ||
| 705 | |||
| 706 | "@expo/metro-file-map": ["@expo/metro-file-map@56.0.3", "", { "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" } }, "sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w=="], | ||
| 707 | |||
| 708 | "@expo/metro-runtime": ["@expo/metro-runtime@55.0.11", "", { "dependencies": { "@expo/log-box": "55.0.12", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-4KKi/jGrIEXi2YGu0hYTVr0CEeRJy5SXbCrz9+KDZkuD3ROwKNpM1DBawni5rhPVovFnR323HBck9GaxhnfrRw=="], | ||
| 709 | |||
| 710 | "@expo/osascript": ["@expo/osascript@2.6.0", "", { "dependencies": { "@expo/spawn-async": "^1.8.0" } }, "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg=="], | ||
| 711 | |||
| 712 | "@expo/package-manager": ["@expo/package-manager@1.12.0", "", { "dependencies": { "@expo/json-file": "^10.2.0", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, "sha512-SWr6093nwBjn94cvElsYZNUnhvs+XtUatUz3h0vAn0IbaWG0B6l/V5ZfOBptX/xq6rMpFG5ibIf/eckLSXw8Gg=="], | ||
| 713 | |||
| 714 | "@expo/plist": ["@expo/plist@0.7.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q=="], | ||
| 715 | |||
| 716 | "@expo/prebuild-config": ["@expo/prebuild-config@56.0.13", "", { "dependencies": { "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/config-types": "^56.0.5", "@expo/image-utils": "^0.10.1", "@expo/json-file": "^10.2.0", "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.1", "expo-modules-autolinking": "~56.0.13", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-caR1karpDasbNmM+LrcHKZrSnyEYdmxm7kedq+WjiuZg+9XAW5sbEjojo2i9Dq6cfbDJPyr7I0yEprLabnvmpA=="], | ||
| 717 | |||
| 718 | "@expo/require-utils": ["@expo/require-utils@56.1.3", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ=="], | ||
| 719 | |||
| 720 | "@expo/router-server": ["@expo/router-server@56.0.12", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^56.0.13", "expo": "*", "expo-constants": "^56.0.16", "expo-font": "^56.0.5", "expo-router": "*", "expo-server": "^56.0.4", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-RqKV2/Z8BH/z8l0ngSpG6//5xxJPaF5dTQvSfPQ0nrvCjikGMeIvyj3B9BeLnmZZhxb3gBtXqrj3irAoiIp2aQ=="], | ||
| 721 | |||
| 722 | "@expo/schema-utils": ["@expo/schema-utils@56.0.1", "", {}, "sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w=="], | ||
| 723 | |||
| 724 | "@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="], | ||
| 725 | |||
| 726 | "@expo/spawn-async": ["@expo/spawn-async@1.8.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw=="], | ||
| 727 | |||
| 728 | "@expo/sudo-prompt": ["@expo/sudo-prompt@9.3.2", "", {}, "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw=="], | ||
| 729 | |||
| 730 | "@expo/ui": ["@expo/ui@56.0.14", "", { "dependencies": { "sf-symbols-typescript": "^2.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@babel/core": "*", "expo": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-reanimated": "*", "react-native-worklets": "*" }, "optionalPeers": ["@babel/core", "react-dom", "react-native-reanimated", "react-native-worklets"] }, "sha512-0Wr8nsvk2C+BmhmZDQzYr/hxxddHK+ajuJ7ahacUvxt+gQnEXwbueTm0S/hk/54YGASEgplrPGDuR5zzcY+IZg=="], | ||
| 731 | |||
| 732 | "@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.6", "", {}, "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q=="], | ||
| 733 | |||
| 734 | "@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="], | ||
| 735 | |||
| 486 | "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], | 736 | "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], |
| 487 | 737 | ||
| 488 | "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], | 738 | "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], |
| ... | @@ -559,12 +809,20 @@ | ... | @@ -559,12 +809,20 @@ |
| 559 | 809 | ||
| 560 | "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], | 810 | "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], |
| 561 | 811 | ||
| 812 | "@isaacs/ttlcache": ["@isaacs/ttlcache@1.4.1", "", {}, "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA=="], | ||
| 813 | |||
| 814 | "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], | ||
| 815 | |||
| 816 | "@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="], | ||
| 817 | |||
| 562 | "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], | 818 | "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], |
| 563 | 819 | ||
| 564 | "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], | 820 | "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], |
| 565 | 821 | ||
| 566 | "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], | 822 | "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], |
| 567 | 823 | ||
| 824 | "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], | ||
| 825 | |||
| 568 | "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], | 826 | "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], |
| 569 | 827 | ||
| 570 | "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], | 828 | "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], |
| ... | @@ -735,6 +993,80 @@ | ... | @@ -735,6 +993,80 @@ |
| 735 | 993 | ||
| 736 | "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], | 994 | "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], |
| 737 | 995 | ||
| 996 | "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], | ||
| 997 | |||
| 998 | "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], | ||
| 999 | |||
| 1000 | "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], | ||
| 1001 | |||
| 1002 | "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], | ||
| 1003 | |||
| 1004 | "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], | ||
| 1005 | |||
| 1006 | "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], | ||
| 1007 | |||
| 1008 | "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], | ||
| 1009 | |||
| 1010 | "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], | ||
| 1011 | |||
| 1012 | "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], | ||
| 1013 | |||
| 1014 | "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], | ||
| 1015 | |||
| 1016 | "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], | ||
| 1017 | |||
| 1018 | "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], | ||
| 1019 | |||
| 1020 | "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], | ||
| 1021 | |||
| 1022 | "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], | ||
| 1023 | |||
| 1024 | "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], | ||
| 1025 | |||
| 1026 | "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], | ||
| 1027 | |||
| 1028 | "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], | ||
| 1029 | |||
| 1030 | "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], | ||
| 1031 | |||
| 1032 | "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], | ||
| 1033 | |||
| 1034 | "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], | ||
| 1035 | |||
| 1036 | "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], | ||
| 1037 | |||
| 1038 | "@react-native-masked-view/masked-view": ["@react-native-masked-view/masked-view@0.3.2", "", { "peerDependencies": { "react": ">=16", "react-native": ">=0.57" } }, "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ=="], | ||
| 1039 | |||
| 1040 | "@react-native-menu/menu": ["@react-native-menu/menu@2.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-hb8Mirw6aKPGONhgo52IiNpwHtISVrgCT3rMdFX1qS7eOFNzOcQh8d2UDnaH5zVpxN+QuvWtaaiRMGFpIjzdtA=="], | ||
| 1041 | |||
| 1042 | "@react-native/assets-registry": ["@react-native/assets-registry@0.85.3", "", {}, "sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg=="], | ||
| 1043 | |||
| 1044 | "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.85.3", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.85.3" } }, "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA=="], | ||
| 1045 | |||
| 1046 | "@react-native/babel-preset": ["@react-native/babel-preset@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2", "@babel/plugin-transform-react-jsx-self": "^7.24.7", "@babel/plugin-transform-react-jsx-source": "^7.24.7", "@babel/plugin-transform-regenerator": "^7.24.7", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-syntax-hermes-parser": "0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" } }, "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw=="], | ||
| 1047 | |||
| 1048 | "@react-native/codegen": ["@react-native/codegen@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", "hermes-parser": "0.33.3", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "tinyglobby": "^0.2.15", "yargs": "^17.6.2" } }, "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA=="], | ||
| 1049 | |||
| 1050 | "@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.85.3", "", { "dependencies": { "@react-native/dev-middleware": "0.85.3", "debug": "^4.4.0", "invariant": "^2.2.4", "metro": "^0.84.3", "metro-config": "^0.84.3", "metro-core": "^0.84.3", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*", "@react-native/metro-config": "0.85.3" }, "optionalPeers": ["@react-native-community/cli", "@react-native/metro-config"] }, "sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw=="], | ||
| 1051 | |||
| 1052 | "@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.85.3", "", {}, "sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A=="], | ||
| 1053 | |||
| 1054 | "@react-native/debugger-shell": ["@react-native/debugger-shell@0.85.3", "", { "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", "fb-dotslash": "0.5.8" } }, "sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ=="], | ||
| 1055 | |||
| 1056 | "@react-native/dev-middleware": ["@react-native/dev-middleware@0.85.3", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.85.3", "@react-native/debugger-shell": "0.85.3", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^7.5.10" } }, "sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA=="], | ||
| 1057 | |||
| 1058 | "@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.85.3", "", {}, "sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA=="], | ||
| 1059 | |||
| 1060 | "@react-native/js-polyfills": ["@react-native/js-polyfills@0.85.3", "", {}, "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A=="], | ||
| 1061 | |||
| 1062 | "@react-native/metro-babel-transformer": ["@react-native/metro-babel-transformer@0.85.3", "", { "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.85.3", "hermes-parser": "0.33.3", "nullthrows": "^1.1.1" } }, "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ=="], | ||
| 1063 | |||
| 1064 | "@react-native/metro-config": ["@react-native/metro-config@0.85.3", "", { "dependencies": { "@react-native/js-polyfills": "0.85.3", "@react-native/metro-babel-transformer": "0.85.3", "metro-config": "^0.84.3", "metro-runtime": "^0.84.3" } }, "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA=="], | ||
| 1065 | |||
| 1066 | "@react-native/normalize-colors": ["@react-native/normalize-colors@0.85.3", "", {}, "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw=="], | ||
| 1067 | |||
| 1068 | "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.85.3", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.85.3" }, "optionalPeers": ["@types/react"] }, "sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig=="], | ||
| 1069 | |||
| 738 | "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="], | 1070 | "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="], |
| 739 | 1071 | ||
| 740 | "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A=="], | 1072 | "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A=="], |
| ... | @@ -771,24 +1103,26 @@ | ... | @@ -771,24 +1103,26 @@ |
| 771 | 1103 | ||
| 772 | "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], | 1104 | "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], |
| 773 | 1105 | ||
| 774 | "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], | 1106 | "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], |
| 775 | 1107 | ||
| 776 | "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], | 1108 | "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], |
| 777 | 1109 | ||
| 778 | "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], | 1110 | "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], |
| 779 | 1111 | ||
| 780 | "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], | 1112 | "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], |
| 781 | 1113 | ||
| 782 | "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], | 1114 | "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], |
| 783 | 1115 | ||
| 784 | "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], | 1116 | "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], |
| 785 | 1117 | ||
| 786 | "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], | 1118 | "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], |
| 787 | 1119 | ||
| 788 | "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | 1120 | "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], |
| 789 | 1121 | ||
| 790 | "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], | 1122 | "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], |
| 791 | 1123 | ||
| 1124 | "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], | ||
| 1125 | |||
| 792 | "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], | 1126 | "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], |
| 793 | 1127 | ||
| 794 | "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], | 1128 | "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], |
| ... | @@ -803,6 +1137,8 @@ | ... | @@ -803,6 +1137,8 @@ |
| 803 | 1137 | ||
| 804 | "@t3tools/marketing": ["@t3tools/marketing@workspace:apps/marketing"], | 1138 | "@t3tools/marketing": ["@t3tools/marketing@workspace:apps/marketing"], |
| 805 | 1139 | ||
| 1140 | "@t3tools/mobile": ["@t3tools/mobile@workspace:apps/mobile"], | ||
| 1141 | |||
| 806 | "@t3tools/oxlint-plugin-t3code": ["@t3tools/oxlint-plugin-t3code@workspace:oxlint-plugin-t3code"], | 1142 | "@t3tools/oxlint-plugin-t3code": ["@t3tools/oxlint-plugin-t3code@workspace:oxlint-plugin-t3code"], |
| 807 | 1143 | ||
| 808 | "@t3tools/scripts": ["@t3tools/scripts@workspace:scripts"], | 1144 | "@t3tools/scripts": ["@t3tools/scripts@workspace:scripts"], |
| ... | @@ -873,8 +1209,16 @@ | ... | @@ -873,8 +1209,16 @@ |
| 873 | 1209 | ||
| 874 | "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="], | 1210 | "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="], |
| 875 | 1211 | ||
| 1212 | "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], | ||
| 1213 | |||
| 1214 | "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], | ||
| 1215 | |||
| 1216 | "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], | ||
| 1217 | |||
| 876 | "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], | 1218 | "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], |
| 877 | 1219 | ||
| 1220 | "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], | ||
| 1221 | |||
| 878 | "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], | 1222 | "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], |
| 879 | 1223 | ||
| 880 | "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], | 1224 | "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], |
| ... | @@ -893,14 +1237,24 @@ | ... | @@ -893,14 +1237,24 @@ |
| 893 | 1237 | ||
| 894 | "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], | 1238 | "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], |
| 895 | 1239 | ||
| 1240 | "@types/emscripten": ["@types/emscripten@1.41.5", "", {}, "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="], | ||
| 1241 | |||
| 896 | "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], | 1242 | "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], |
| 897 | 1243 | ||
| 898 | "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], | 1244 | "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], |
| 899 | 1245 | ||
| 1246 | "@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="], | ||
| 1247 | |||
| 900 | "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], | 1248 | "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], |
| 901 | 1249 | ||
| 902 | "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], | 1250 | "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], |
| 903 | 1251 | ||
| 1252 | "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], | ||
| 1253 | |||
| 1254 | "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], | ||
| 1255 | |||
| 1256 | "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], | ||
| 1257 | |||
| 904 | "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], | 1258 | "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], |
| 905 | 1259 | ||
| 906 | "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], | 1260 | "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], |
| ... | @@ -917,6 +1271,8 @@ | ... | @@ -917,6 +1271,8 @@ |
| 917 | 1271 | ||
| 918 | "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], | 1272 | "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], |
| 919 | 1273 | ||
| 1274 | "@types/react-test-renderer": ["@types/react-test-renderer@19.1.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ=="], | ||
| 1275 | |||
| 920 | "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], | 1276 | "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], |
| 921 | 1277 | ||
| 922 | "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], | 1278 | "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], |
| ... | @@ -925,6 +1281,10 @@ | ... | @@ -925,6 +1281,10 @@ |
| 925 | 1281 | ||
| 926 | "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], | 1282 | "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], |
| 927 | 1283 | ||
| 1284 | "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], | ||
| 1285 | |||
| 1286 | "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], | ||
| 1287 | |||
| 928 | "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], | 1288 | "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], |
| 929 | 1289 | ||
| 930 | "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260527.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260527.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260527.2" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-piqkDwikVeizCFqA1lcwI5F4wOAtBdxuliWe77ApBNRyBPPvfCJB+u/HYi9/8t5nd0sWvFs6/qt/AzJ1CCoykQ=="], | 1290 | "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260527.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260527.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260527.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260527.2" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-piqkDwikVeizCFqA1lcwI5F4wOAtBdxuliWe77ApBNRyBPPvfCJB+u/HYi9/8t5nd0sWvFs6/qt/AzJ1CCoykQ=="], |
| ... | @@ -989,34 +1349,50 @@ | ... | @@ -989,34 +1349,50 @@ |
| 989 | 1349 | ||
| 990 | "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], | 1350 | "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], |
| 991 | 1351 | ||
| 1352 | "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], | ||
| 1353 | |||
| 992 | "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], | 1354 | "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], |
| 993 | 1355 | ||
| 994 | "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], | 1356 | "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], |
| 995 | 1357 | ||
| 996 | "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], | 1358 | "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], |
| 1359 | |||
| 1360 | "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], | ||
| 997 | 1361 | ||
| 998 | "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], | 1362 | "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], |
| 999 | 1363 | ||
| 1364 | "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], | ||
| 1365 | |||
| 1000 | "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], | 1366 | "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], |
| 1001 | 1367 | ||
| 1002 | "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], | 1368 | "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], |
| 1003 | 1369 | ||
| 1004 | "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], | 1370 | "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], |
| 1005 | 1371 | ||
| 1372 | "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], | ||
| 1373 | |||
| 1374 | "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], | ||
| 1375 | |||
| 1006 | "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], | 1376 | "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], |
| 1007 | 1377 | ||
| 1008 | "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], | 1378 | "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], |
| 1009 | 1379 | ||
| 1010 | "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], | 1380 | "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], |
| 1011 | 1381 | ||
| 1012 | "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], | 1382 | "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], |
| 1013 | 1383 | ||
| 1384 | "arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="], | ||
| 1385 | |||
| 1014 | "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], | 1386 | "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], |
| 1015 | 1387 | ||
| 1388 | "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], | ||
| 1389 | |||
| 1016 | "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], | 1390 | "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], |
| 1017 | 1391 | ||
| 1018 | "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], | 1392 | "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], |
| 1019 | 1393 | ||
| 1394 | "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], | ||
| 1395 | |||
| 1020 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], | 1396 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], |
| 1021 | 1397 | ||
| 1022 | "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], | 1398 | "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], |
| ... | @@ -1029,12 +1405,34 @@ | ... | @@ -1029,12 +1405,34 @@ |
| 1029 | 1405 | ||
| 1030 | "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], | 1406 | "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], |
| 1031 | 1407 | ||
| 1408 | "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], | ||
| 1409 | |||
| 1410 | "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.13.0", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A=="], | ||
| 1411 | |||
| 1412 | "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="], | ||
| 1413 | |||
| 1032 | "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], | 1414 | "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], |
| 1033 | 1415 | ||
| 1416 | "babel-plugin-react-native-web": ["babel-plugin-react-native-web@0.21.2", "", {}, "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA=="], | ||
| 1417 | |||
| 1418 | "babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.33.3", "", { "dependencies": { "hermes-parser": "0.33.3" } }, "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA=="], | ||
| 1419 | |||
| 1420 | "babel-plugin-transform-flow-enums": ["babel-plugin-transform-flow-enums@0.0.2", "", { "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } }, "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ=="], | ||
| 1421 | |||
| 1422 | "babel-preset-expo": ["babel-preset-expo@56.0.13", "", { "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-transform-async-generator-functions": "^7.25.4", "@babel/plugin-transform-async-to-generator": "^7.24.7", "@babel/plugin-transform-block-scoping": "^7.25.0", "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", "@babel/plugin-transform-classes": "^7.25.4", "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", "@babel/plugin-transform-for-of": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", "@babel/plugin-transform-react-display-name": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.28.6", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", "babel-plugin-syntax-hermes-parser": "^0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", "expo-widgets": "^56.0.15", "react-refresh": ">=0.14.0 <1.0.0" }, "optionalPeers": ["@babel/runtime", "expo", "expo-widgets"] }, "sha512-+CxxAQrN95N+/dF4AUJXNxEh5cEv4yhxb4CM5ijdc2OeIIw+hxzYh2OM1X7QHIm6hkT66H4vJCTT636yjJ8MnQ=="], | ||
| 1423 | |||
| 1034 | "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], | 1424 | "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], |
| 1035 | 1425 | ||
| 1426 | "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], | ||
| 1427 | |||
| 1428 | "barcode-detector": ["barcode-detector@3.1.3", "", { "dependencies": { "zxing-wasm": "3.0.3" } }, "sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw=="], | ||
| 1429 | |||
| 1430 | "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], | ||
| 1431 | |||
| 1036 | "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="], | 1432 | "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="], |
| 1037 | 1433 | ||
| 1434 | "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], | ||
| 1435 | |||
| 1038 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], | 1436 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], |
| 1039 | 1437 | ||
| 1040 | "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], | 1438 | "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], |
| ... | @@ -1045,12 +1443,22 @@ | ... | @@ -1045,12 +1443,22 @@ |
| 1045 | 1443 | ||
| 1046 | "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], | 1444 | "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], |
| 1047 | 1445 | ||
| 1446 | "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], | ||
| 1447 | |||
| 1448 | "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], | ||
| 1449 | |||
| 1450 | "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], | ||
| 1451 | |||
| 1048 | "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], | 1452 | "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], |
| 1049 | 1453 | ||
| 1050 | "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], | 1454 | "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], |
| 1051 | 1455 | ||
| 1456 | "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], | ||
| 1457 | |||
| 1052 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], | 1458 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], |
| 1053 | 1459 | ||
| 1460 | "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], | ||
| 1461 | |||
| 1054 | "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], | 1462 | "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], |
| 1055 | 1463 | ||
| 1056 | "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], | 1464 | "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], |
| ... | @@ -1067,12 +1475,16 @@ | ... | @@ -1067,12 +1475,16 @@ |
| 1067 | 1475 | ||
| 1068 | "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], | 1476 | "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], |
| 1069 | 1477 | ||
| 1478 | "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], | ||
| 1479 | |||
| 1070 | "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="], | 1480 | "caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="], |
| 1071 | 1481 | ||
| 1072 | "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], | 1482 | "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], |
| 1073 | 1483 | ||
| 1074 | "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], | 1484 | "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], |
| 1075 | 1485 | ||
| 1486 | "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], | ||
| 1487 | |||
| 1076 | "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], | 1488 | "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], |
| 1077 | 1489 | ||
| 1078 | "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], | 1490 | "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], |
| ... | @@ -1083,30 +1495,52 @@ | ... | @@ -1083,30 +1495,52 @@ |
| 1083 | 1495 | ||
| 1084 | "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], | 1496 | "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], |
| 1085 | 1497 | ||
| 1498 | "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], | ||
| 1499 | |||
| 1500 | "chromium-edge-launcher": ["chromium-edge-launcher@0.3.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", "mkdirp": "^1.0.4" } }, "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA=="], | ||
| 1501 | |||
| 1086 | "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], | 1502 | "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], |
| 1087 | 1503 | ||
| 1088 | "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], | 1504 | "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], |
| 1089 | 1505 | ||
| 1506 | "cli-cursor": ["cli-cursor@2.1.0", "", { "dependencies": { "restore-cursor": "^2.0.0" } }, "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw=="], | ||
| 1507 | |||
| 1508 | "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], | ||
| 1509 | |||
| 1090 | "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], | 1510 | "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], |
| 1091 | 1511 | ||
| 1512 | "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], | ||
| 1513 | |||
| 1092 | "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], | 1514 | "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], |
| 1093 | 1515 | ||
| 1516 | "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], | ||
| 1517 | |||
| 1094 | "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], | 1518 | "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], |
| 1095 | 1519 | ||
| 1096 | "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], | 1520 | "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], |
| 1097 | 1521 | ||
| 1098 | "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], | 1522 | "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], |
| 1099 | 1523 | ||
| 1524 | "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], | ||
| 1525 | |||
| 1100 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], | 1526 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], |
| 1101 | 1527 | ||
| 1102 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], | 1528 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], |
| 1103 | 1529 | ||
| 1530 | "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], | ||
| 1531 | |||
| 1104 | "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], | 1532 | "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], |
| 1105 | 1533 | ||
| 1106 | "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], | 1534 | "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], |
| 1107 | 1535 | ||
| 1108 | "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], | 1536 | "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], |
| 1109 | 1537 | ||
| 1538 | "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], | ||
| 1539 | |||
| 1540 | "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], | ||
| 1541 | |||
| 1542 | "connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="], | ||
| 1543 | |||
| 1110 | "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], | 1544 | "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], |
| 1111 | 1545 | ||
| 1112 | "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], | 1546 | "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], |
| ... | @@ -1119,6 +1553,8 @@ | ... | @@ -1119,6 +1553,8 @@ |
| 1119 | 1553 | ||
| 1120 | "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], | 1554 | "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], |
| 1121 | 1555 | ||
| 1556 | "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], | ||
| 1557 | |||
| 1122 | "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], | 1558 | "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], |
| 1123 | 1559 | ||
| 1124 | "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], | 1560 | "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], |
| ... | @@ -1127,20 +1563,30 @@ | ... | @@ -1127,20 +1563,30 @@ |
| 1127 | 1563 | ||
| 1128 | "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], | 1564 | "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], |
| 1129 | 1565 | ||
| 1130 | "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], | 1566 | "css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="], |
| 1131 | 1567 | ||
| 1132 | "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], | 1568 | "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], |
| 1133 | 1569 | ||
| 1570 | "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], | ||
| 1571 | |||
| 1134 | "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], | 1572 | "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], |
| 1135 | 1573 | ||
| 1136 | "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], | 1574 | "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], |
| 1137 | 1575 | ||
| 1576 | "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="], | ||
| 1577 | |||
| 1138 | "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], | 1578 | "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], |
| 1139 | 1579 | ||
| 1140 | "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], | 1580 | "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], |
| 1141 | 1581 | ||
| 1582 | "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], | ||
| 1583 | |||
| 1142 | "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], | 1584 | "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], |
| 1143 | 1585 | ||
| 1586 | "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], | ||
| 1587 | |||
| 1588 | "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], | ||
| 1589 | |||
| 1144 | "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], | 1590 | "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], |
| 1145 | 1591 | ||
| 1146 | "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], | 1592 | "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], |
| ... | @@ -1157,10 +1603,14 @@ | ... | @@ -1157,10 +1603,14 @@ |
| 1157 | 1603 | ||
| 1158 | "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], | 1604 | "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], |
| 1159 | 1605 | ||
| 1606 | "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], | ||
| 1607 | |||
| 1160 | "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], | 1608 | "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], |
| 1161 | 1609 | ||
| 1162 | "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], | 1610 | "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], |
| 1163 | 1611 | ||
| 1612 | "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], | ||
| 1613 | |||
| 1164 | "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], | 1614 | "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="], |
| 1165 | 1615 | ||
| 1166 | "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], | 1616 | "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], |
| ... | @@ -1169,6 +1619,10 @@ | ... | @@ -1169,6 +1619,10 @@ |
| 1169 | 1619 | ||
| 1170 | "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], | 1620 | "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], |
| 1171 | 1621 | ||
| 1622 | "dnssd-advertise": ["dnssd-advertise@1.1.4", "", {}, "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA=="], | ||
| 1623 | |||
| 1624 | "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], | ||
| 1625 | |||
| 1172 | "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], | 1626 | "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], |
| 1173 | 1627 | ||
| 1174 | "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], | 1628 | "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], |
| ... | @@ -1213,6 +1667,8 @@ | ... | @@ -1213,6 +1667,8 @@ |
| 1213 | 1667 | ||
| 1214 | "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], | 1668 | "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], |
| 1215 | 1669 | ||
| 1670 | "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], | ||
| 1671 | |||
| 1216 | "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], | 1672 | "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], |
| 1217 | 1673 | ||
| 1218 | "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], | 1674 | "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], |
| ... | @@ -1239,6 +1695,8 @@ | ... | @@ -1239,6 +1695,8 @@ |
| 1239 | 1695 | ||
| 1240 | "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], | 1696 | "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], |
| 1241 | 1697 | ||
| 1698 | "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], | ||
| 1699 | |||
| 1242 | "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], | 1700 | "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], |
| 1243 | 1701 | ||
| 1244 | "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], | 1702 | "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], |
| ... | @@ -1247,6 +1705,76 @@ | ... | @@ -1247,6 +1705,76 @@ |
| 1247 | 1705 | ||
| 1248 | "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], | 1706 | "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], |
| 1249 | 1707 | ||
| 1708 | "expo": ["expo@56.0.6", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^56.1.12", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.8", "@expo/devtools": "~56.0.2", "@expo/dom-webview": "~56.0.5", "@expo/fingerprint": "^0.19.3", "@expo/local-build-cache-provider": "^56.0.8", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.13", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~56.0.13", "expo-asset": "~56.0.15", "expo-constants": "~56.0.16", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-keep-awake": "~56.0.3", "expo-modules-autolinking": "~56.0.14", "expo-modules-core": "~56.0.13", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-zcFa/+6hGtzCUlcrGiusvzr/PIoNBAnjj4PlAFrvbAOZcVOj6c9Mp7lRSn9XYJk8Ok6pssQWt6dP4llJlKmYRQ=="], | ||
| 1709 | |||
| 1710 | "expo-asset": ["expo-asset@56.0.15", "", { "dependencies": { "@expo/image-utils": "^0.10.1", "expo-constants": "~56.0.16" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ=="], | ||
| 1711 | |||
| 1712 | "expo-build-properties": ["expo-build-properties@56.0.15", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-3OlfTnBE6BIFxchjXzb0OlgDcWw19fxhIzpIZqgcgzZUVjyn4gCrQuNcsfazVVddBypwkEzOVfwArPROIk4J7g=="], | ||
| 1713 | |||
| 1714 | "expo-camera": ["expo-camera@56.0.7", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-c8z+UheidFintQyP9XLEDP43aK4PS/o9+TFLW0zEOjdqkYCBgoWq6Mw/Ps62kjBeftFY7xrp5ZLITbenNvbTaw=="], | ||
| 1715 | |||
| 1716 | "expo-clipboard": ["expo-clipboard@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-8mCdhmAomm0yBIonJFjAhKUXvSkc2avdNh4+rBwoe7DSWF2AC4w3uy+pa419rvVFbTyVxOBmh83UHAbUwD6qAg=="], | ||
| 1717 | |||
| 1718 | "expo-constants": ["expo-constants@56.0.16", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA=="], | ||
| 1719 | |||
| 1720 | "expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="], | ||
| 1721 | |||
| 1722 | "expo-dev-client": ["expo-dev-client@56.0.16", "", { "dependencies": { "expo-dev-launcher": "~56.0.16", "expo-dev-menu": "~56.0.15", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" }, "peerDependencies": { "expo": "*" } }, "sha512-mxmGA6YSP4KiMB4bREpriQ4K6EaS4tcm0eh1+LtAzgFCytq+Y4WxMfIvFe3B5kXlSpA0ohMLdAN0AUzU0xHGQg=="], | ||
| 1723 | |||
| 1724 | "expo-dev-launcher": ["expo-dev-launcher@56.0.16", "", { "dependencies": { "@expo/schema-utils": "^56.0.0", "expo-dev-menu": "~56.0.15", "expo-manifests": "~56.0.4" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-3t2PCX2lCKetKL8EgRRo2tzSlGh1zcuaWuwp3V0k4/3nuM7pztyImaR6Sm3HUyarDOofAIPX1hIIxnuAfk5cnw=="], | ||
| 1725 | |||
| 1726 | "expo-dev-menu": ["expo-dev-menu@56.0.15", "", { "dependencies": { "expo-dev-menu-interface": "~56.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FY6Y5sZkNXxPBGDgC51ZArOi8N7Y8wpXwanTClFO36IVMoVf7BBqhjW13KpDecvJONtEtaUeNIAt9C25PO8MOQ=="], | ||
| 1727 | |||
| 1728 | "expo-dev-menu-interface": ["expo-dev-menu-interface@56.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-odATx0ZL/Kis10sKSBiKiGQxAB6coSi/KQtKcMhnQVNno6FkRh5/4e5BqcEvpq2rNMTiQp4ytNAQHtdwbPXvGA=="], | ||
| 1729 | |||
| 1730 | "expo-eas-client": ["expo-eas-client@56.0.1", "", {}, "sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA=="], | ||
| 1731 | |||
| 1732 | "expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="], | ||
| 1733 | |||
| 1734 | "expo-font": ["expo-font@56.0.5", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-WLoDu9hlEgPRKXJRR01HFLJ6Z2tFcORX/WFPRYBndmYc5kjQrFGH/j4BRaF3aBRPyYEAUXiUJybNLXkKCwEXQw=="], | ||
| 1735 | |||
| 1736 | "expo-glass-effect": ["expo-glass-effect@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-xI9rXtDwi7RW82uAlfyaXO6+k21ApWJ2tHAWYqPr/FjfmZbKsgNJ4Q0iZzGPCwboqjTGxaRZ61SZxBl8hDt5iA=="], | ||
| 1737 | |||
| 1738 | "expo-haptics": ["expo-haptics@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-ycoahZJnR9tWAVh/0mJYxbETtHRYaWjiWS8cHlP6aDGU6Q6Y8rZ5NKsuBwWw6HR2Pe30mfVFgbF2HrBR6gtYmw=="], | ||
| 1739 | |||
| 1740 | "expo-image-loader": ["expo-image-loader@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA=="], | ||
| 1741 | |||
| 1742 | "expo-image-picker": ["expo-image-picker@56.0.14", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-OKirR7XcJC3awoSGYDzFIdPibTEzw9FL1KHoTSxf86ZmssBiFbJvJhb1JQAVe2Y/U0sQ4e9gMoOrCKwqJnGDEQ=="], | ||
| 1743 | |||
| 1744 | "expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="], | ||
| 1745 | |||
| 1746 | "expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="], | ||
| 1747 | |||
| 1748 | "expo-linking": ["expo-linking@56.0.12", "", { "dependencies": { "expo-constants": "~56.0.16", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-EJ+YoazVqlrUXMAARo1iTExpqEGjuKJDGiE/P1K+A3m5hs+2Uf8F9ucqpq9k5dizeiaV2D8B9+uLvqMHFzGGsQ=="], | ||
| 1749 | |||
| 1750 | "expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="], | ||
| 1751 | |||
| 1752 | "expo-modules-autolinking": ["expo-modules-autolinking@56.0.14", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-9ugtZkheNPYDkW4DZopY1rH2BCbUICaafUEPxRgbLDR5UNRF5K3cdHMIMEt8pxZPq2+eX4wCm+6pbSvdY/DPHg=="], | ||
| 1753 | |||
| 1754 | "expo-modules-core": ["expo-modules-core@56.0.13", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "~0.0.9", "expo-modules-jsi": "~56.0.7", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" }, "optionalPeers": ["react-native-worklets"] }, "sha512-3Hgpi9Q1O0XqoesQtgFY7qhfDsNA3bJtdCJotEqdE42+N8Zv/LJACbNgIyFN/XrnMDzfF5rozh0vNWaRT0/eXQ=="], | ||
| 1755 | |||
| 1756 | "expo-modules-jsi": ["expo-modules-jsi@56.0.7", "", { "peerDependencies": { "react-native": "*" } }, "sha512-iBAj4Xeh/8HT201VVxFlmf+VBfmtQV1ZUoJdLQQENm0+j9gnD2QswZLJyNo3CmNNXl46esJeLR5lpGpYZts/zA=="], | ||
| 1757 | |||
| 1758 | "expo-paste-input": ["expo-paste-input@0.1.15", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-wV0z1s0/Q4MB+lNiSwbMfck+1Im6sDIcgyCi5Y0aF35n5kcM4FHSOjmRfzn3UbkMlvxmYfDiHeHvIqn22qatqA=="], | ||
| 1759 | |||
| 1760 | "expo-router": ["expo-router@56.2.7", "", { "dependencies": { "@expo/log-box": "^56.0.12", "@expo/metro-runtime": "^56.0.13", "@expo/schema-utils": "^56.0.0", "@expo/ui": "^56.0.14", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "client-only": "^0.0.1", "color": "^4.2.3", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-glass-effect": "^56.0.4", "expo-server": "^56.0.4", "expo-symbols": "^56.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", "react-native-screens": "^4.25.2", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "vaul": "^1.1.2" }, "peerDependencies": { "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^56.0.16", "expo-linking": "^56.0.12", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, "optionalPeers": ["@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-T7MSugHfj6XDrVJG8dCkP5EEAWeCkPrkkxqKCqCRokXmBKTAiRGXsmPsgHzOXhr/5MxGDJXhj5ON19uWoCevDA=="], | ||
| 1761 | |||
| 1762 | "expo-secure-store": ["expo-secure-store@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ=="], | ||
| 1763 | |||
| 1764 | "expo-server": ["expo-server@56.0.4", "", {}, "sha512-4dJ57KuAwDl7eQGD6aG9kTzBIftWAfHH1+6Zxy7NcPCBrKYis3/H5enGUz1asH8HHhONXfJ5BdJqfEWAEAgWxA=="], | ||
| 1765 | |||
| 1766 | "expo-splash-screen": ["expo-splash-screen@56.0.10", "", { "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/image-utils": "^0.10.1", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-vDIlo8hzt9HlCZQ0kSY66v83D1WEXOJbVMeyPDfXDu9tbDdPMNUyDpi4WGJXikAjxnAKfbt5Mv5NnEbxINy+VA=="], | ||
| 1767 | |||
| 1768 | "expo-structured-headers": ["expo-structured-headers@56.0.0", "", {}, "sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw=="], | ||
| 1769 | |||
| 1770 | "expo-symbols": ["expo-symbols@56.0.5", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-RIukH0Xo80C7RU8qreipL2SPy2Py+Km8JFPbCmbPQpHkM3DW9Znlmg6VfhzbtUOlO5EuNSF0lAJ3l2VJi6qYrw=="], | ||
| 1771 | |||
| 1772 | "expo-updates": ["expo-updates@56.0.17", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/plist": "^0.7.0", "@expo/spawn-async": "^1.8.0", "arg": "^4.1.0", "chalk": "^4.1.2", "debug": "^4.3.4", "expo-eas-client": "~56.0.0", "expo-manifests": "~56.0.4", "expo-structured-headers": "~56.0.0", "expo-updates-interface": "~56.0.1", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "nullthrows": "^1.1.1", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*", "expo-dev-client": "*", "react": "*", "react-native": "*" }, "optionalPeers": ["expo-dev-client"], "bin": { "expo-updates": "bin/cli.js" } }, "sha512-4005IRQQ7Vpwb01X3NSG0SwY61HTJzT2pZ0OrkeZ6guQE1WKiRfUKisekRgYXTI8sPbxSHUc9aOpIx6yC49XZQ=="], | ||
| 1773 | |||
| 1774 | "expo-updates-interface": ["expo-updates-interface@56.0.2", "", { "peerDependencies": { "expo": "*" } }, "sha512-eWTwSZ9y8vrULG2oBn2TQSSIwBGSq/TxGJ3jY6tuVS2FWH/ASRIiKs3zkUZTRoC3ZuV2alz0mUClYV7nNrFx8g=="], | ||
| 1775 | |||
| 1776 | "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], | ||
| 1777 | |||
| 1250 | "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], | 1778 | "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], |
| 1251 | 1779 | ||
| 1252 | "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], | 1780 | "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], |
| ... | @@ -1263,25 +1791,37 @@ | ... | @@ -1263,25 +1791,37 @@ |
| 1263 | 1791 | ||
| 1264 | "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], | 1792 | "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], |
| 1265 | 1793 | ||
| 1794 | "fb-dotslash": ["fb-dotslash@0.5.8", "", { "bin": { "dotslash": "bin/dotslash" } }, "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA=="], | ||
| 1795 | |||
| 1796 | "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], | ||
| 1797 | |||
| 1266 | "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], | 1798 | "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], |
| 1267 | 1799 | ||
| 1268 | "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], | 1800 | "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], |
| 1269 | 1801 | ||
| 1802 | "fetch-nodeshim": ["fetch-nodeshim@0.4.10", "", {}, "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w=="], | ||
| 1803 | |||
| 1270 | "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], | 1804 | "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], |
| 1271 | 1805 | ||
| 1272 | "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], | 1806 | "filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="], |
| 1807 | |||
| 1808 | "finalhandler": ["finalhandler@1.1.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "~2.3.0", "parseurl": "~1.3.3", "statuses": "~1.5.0", "unpipe": "~1.0.0" } }, "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA=="], | ||
| 1273 | 1809 | ||
| 1274 | "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], | 1810 | "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], |
| 1275 | 1811 | ||
| 1276 | "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], | 1812 | "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], |
| 1277 | 1813 | ||
| 1814 | "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="], | ||
| 1815 | |||
| 1278 | "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], | 1816 | "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], |
| 1279 | 1817 | ||
| 1818 | "fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="], | ||
| 1819 | |||
| 1280 | "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], | 1820 | "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], |
| 1281 | 1821 | ||
| 1282 | "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], | 1822 | "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], |
| 1283 | 1823 | ||
| 1284 | "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], | 1824 | "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], |
| 1285 | 1825 | ||
| 1286 | "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], | 1826 | "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], |
| 1287 | 1827 | ||
| ... | @@ -1295,14 +1835,20 @@ | ... | @@ -1295,14 +1835,20 @@ |
| 1295 | 1835 | ||
| 1296 | "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], | 1836 | "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], |
| 1297 | 1837 | ||
| 1838 | "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], | ||
| 1839 | |||
| 1298 | "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], | 1840 | "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], |
| 1299 | 1841 | ||
| 1300 | "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], | 1842 | "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], |
| 1301 | 1843 | ||
| 1302 | "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], | 1844 | "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], |
| 1303 | 1845 | ||
| 1846 | "getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], | ||
| 1847 | |||
| 1304 | "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], | 1848 | "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], |
| 1305 | 1849 | ||
| 1850 | "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], | ||
| 1851 | |||
| 1306 | "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], | 1852 | "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], |
| 1307 | 1853 | ||
| 1308 | "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], | 1854 | "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], |
| ... | @@ -1319,11 +1865,13 @@ | ... | @@ -1319,11 +1865,13 @@ |
| 1319 | 1865 | ||
| 1320 | "h3": ["h3@1.15.6", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ=="], | 1866 | "h3": ["h3@1.15.6", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ=="], |
| 1321 | 1867 | ||
| 1868 | "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], | ||
| 1869 | |||
| 1322 | "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], | 1870 | "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], |
| 1323 | 1871 | ||
| 1324 | "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], | 1872 | "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], |
| 1325 | 1873 | ||
| 1326 | "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], | 1874 | "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], |
| 1327 | 1875 | ||
| 1328 | "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], | 1876 | "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], |
| 1329 | 1877 | ||
| ... | @@ -1349,10 +1897,20 @@ | ... | @@ -1349,10 +1897,20 @@ |
| 1349 | 1897 | ||
| 1350 | "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], | 1898 | "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], |
| 1351 | 1899 | ||
| 1352 | "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], | 1900 | "hermes-compiler": ["hermes-compiler@250829098.0.10", "", {}, "sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w=="], |
| 1901 | |||
| 1902 | "hermes-estree": ["hermes-estree@0.33.3", "", {}, "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg=="], | ||
| 1903 | |||
| 1904 | "hermes-parser": ["hermes-parser@0.33.3", "", { "dependencies": { "hermes-estree": "0.33.3" } }, "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA=="], | ||
| 1905 | |||
| 1906 | "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], | ||
| 1907 | |||
| 1908 | "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], | ||
| 1353 | 1909 | ||
| 1354 | "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], | 1910 | "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], |
| 1355 | 1911 | ||
| 1912 | "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], | ||
| 1913 | |||
| 1356 | "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], | 1914 | "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], |
| 1357 | 1915 | ||
| 1358 | "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], | 1916 | "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], |
| ... | @@ -1365,16 +1923,26 @@ | ... | @@ -1365,16 +1923,26 @@ |
| 1365 | 1923 | ||
| 1366 | "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], | 1924 | "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], |
| 1367 | 1925 | ||
| 1926 | "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], | ||
| 1927 | |||
| 1368 | "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], | 1928 | "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], |
| 1369 | 1929 | ||
| 1930 | "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], | ||
| 1931 | |||
| 1932 | "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], | ||
| 1933 | |||
| 1370 | "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], | 1934 | "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], |
| 1371 | 1935 | ||
| 1936 | "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], | ||
| 1937 | |||
| 1372 | "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], | 1938 | "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], |
| 1373 | 1939 | ||
| 1374 | "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], | 1940 | "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], |
| 1375 | 1941 | ||
| 1376 | "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], | 1942 | "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], |
| 1377 | 1943 | ||
| 1944 | "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], | ||
| 1945 | |||
| 1378 | "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], | 1946 | "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], |
| 1379 | 1947 | ||
| 1380 | "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], | 1948 | "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], |
| ... | @@ -1387,8 +1955,12 @@ | ... | @@ -1387,8 +1955,12 @@ |
| 1387 | 1955 | ||
| 1388 | "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], | 1956 | "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], |
| 1389 | 1957 | ||
| 1958 | "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], | ||
| 1959 | |||
| 1390 | "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], | 1960 | "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], |
| 1391 | 1961 | ||
| 1962 | "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], | ||
| 1963 | |||
| 1392 | "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], | 1964 | "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], |
| 1393 | 1965 | ||
| 1394 | "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], | 1966 | "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], |
| ... | @@ -1419,6 +1991,16 @@ | ... | @@ -1419,6 +1991,16 @@ |
| 1419 | 1991 | ||
| 1420 | "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], | 1992 | "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], |
| 1421 | 1993 | ||
| 1994 | "jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="], | ||
| 1995 | |||
| 1996 | "jest-util": ["jest-util@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", "graceful-fs": "^4.2.9", "picomatch": "^2.2.3" } }, "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA=="], | ||
| 1997 | |||
| 1998 | "jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="], | ||
| 1999 | |||
| 2000 | "jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], | ||
| 2001 | |||
| 2002 | "jimp-compact": ["jimp-compact@0.16.1", "", {}, "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww=="], | ||
| 2003 | |||
| 1422 | "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], | 2004 | "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], |
| 1423 | 2005 | ||
| 1424 | "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], | 2006 | "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], |
| ... | @@ -1427,6 +2009,8 @@ | ... | @@ -1427,6 +2009,8 @@ |
| 1427 | 2009 | ||
| 1428 | "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], | 2010 | "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], |
| 1429 | 2011 | ||
| 2012 | "jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="], | ||
| 2013 | |||
| 1430 | "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], | 2014 | "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], |
| 1431 | 2015 | ||
| 1432 | "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], | 2016 | "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], |
| ... | @@ -1451,35 +2035,43 @@ | ... | @@ -1451,35 +2035,43 @@ |
| 1451 | 2035 | ||
| 1452 | "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], | 2036 | "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], |
| 1453 | 2037 | ||
| 2038 | "lan-network": ["lan-network@0.2.1", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A=="], | ||
| 2039 | |||
| 1454 | "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], | 2040 | "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], |
| 1455 | 2041 | ||
| 2042 | "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], | ||
| 2043 | |||
| 1456 | "lexical": ["lexical@0.41.0", "", {}, "sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA=="], | 2044 | "lexical": ["lexical@0.41.0", "", {}, "sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA=="], |
| 1457 | 2045 | ||
| 1458 | "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], | 2046 | "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], |
| 1459 | 2047 | ||
| 1460 | "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], | 2048 | "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], |
| 2049 | |||
| 2050 | "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], | ||
| 1461 | 2051 | ||
| 1462 | "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], | 2052 | "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], |
| 1463 | 2053 | ||
| 1464 | "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], | 2054 | "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], |
| 1465 | 2055 | ||
| 1466 | "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], | 2056 | "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], |
| 1467 | 2057 | ||
| 1468 | "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], | 2058 | "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], |
| 1469 | 2059 | ||
| 1470 | "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], | 2060 | "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], |
| 1471 | 2061 | ||
| 1472 | "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], | 2062 | "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], |
| 1473 | 2063 | ||
| 1474 | "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], | 2064 | "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], |
| 1475 | 2065 | ||
| 1476 | "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], | 2066 | "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], |
| 1477 | 2067 | ||
| 1478 | "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], | 2068 | "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], |
| 1479 | 2069 | ||
| 1480 | "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], | 2070 | "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], |
| 1481 | 2071 | ||
| 1482 | "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], | 2072 | "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], |
| 2073 | |||
| 2074 | "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], | ||
| 1483 | 2075 | ||
| 1484 | "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], | 2076 | "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], |
| 1485 | 2077 | ||
| ... | @@ -1489,8 +2081,14 @@ | ... | @@ -1489,8 +2081,14 @@ |
| 1489 | 2081 | ||
| 1490 | "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], | 2082 | "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], |
| 1491 | 2083 | ||
| 2084 | "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], | ||
| 2085 | |||
| 2086 | "log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], | ||
| 2087 | |||
| 1492 | "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], | 2088 | "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], |
| 1493 | 2089 | ||
| 2090 | "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], | ||
| 2091 | |||
| 1494 | "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], | 2092 | "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], |
| 1495 | 2093 | ||
| 1496 | "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], | 2094 | "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], |
| ... | @@ -1499,12 +2097,18 @@ | ... | @@ -1499,12 +2097,18 @@ |
| 1499 | 2097 | ||
| 1500 | "lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="], | 2098 | "lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="], |
| 1501 | 2099 | ||
| 2100 | "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], | ||
| 2101 | |||
| 1502 | "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], | 2102 | "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], |
| 1503 | 2103 | ||
| 1504 | "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], | 2104 | "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], |
| 1505 | 2105 | ||
| 2106 | "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], | ||
| 2107 | |||
| 1506 | "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], | 2108 | "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], |
| 1507 | 2109 | ||
| 2110 | "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], | ||
| 2111 | |||
| 1508 | "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], | 2112 | "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], |
| 1509 | 2113 | ||
| 1510 | "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], | 2114 | "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], |
| ... | @@ -1541,12 +2145,44 @@ | ... | @@ -1541,12 +2145,44 @@ |
| 1541 | 2145 | ||
| 1542 | "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], | 2146 | "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], |
| 1543 | 2147 | ||
| 1544 | "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], | 2148 | "mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="], |
| 1545 | 2149 | ||
| 1546 | "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], | 2150 | "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], |
| 1547 | 2151 | ||
| 2152 | "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], | ||
| 2153 | |||
| 1548 | "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], | 2154 | "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], |
| 1549 | 2155 | ||
| 2156 | "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], | ||
| 2157 | |||
| 2158 | "metro": ["metro@0.84.4", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-config": "0.84.4", "metro-core": "0.84.4", "metro-file-map": "0.84.4", "metro-resolver": "0.84.4", "metro-runtime": "0.84.4", "metro-source-map": "0.84.4", "metro-symbolicate": "0.84.4", "metro-transform-plugins": "0.84.4", "metro-transform-worker": "0.84.4", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA=="], | ||
| 2159 | |||
| 2160 | "metro-babel-transformer": ["metro-babel-transformer@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", "metro-cache-key": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g=="], | ||
| 2161 | |||
| 2162 | "metro-cache": ["metro-cache@0.84.4", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.84.4" } }, "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg=="], | ||
| 2163 | |||
| 2164 | "metro-cache-key": ["metro-cache-key@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw=="], | ||
| 2165 | |||
| 2166 | "metro-config": ["metro-config@0.84.4", "", { "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.84.4", "metro-cache": "0.84.4", "metro-core": "0.84.4", "metro-runtime": "0.84.4", "yaml": "^2.6.1" } }, "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q=="], | ||
| 2167 | |||
| 2168 | "metro-core": ["metro-core@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.84.4" } }, "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ=="], | ||
| 2169 | |||
| 2170 | "metro-file-map": ["metro-file-map@0.84.4", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ=="], | ||
| 2171 | |||
| 2172 | "metro-minify-terser": ["metro-minify-terser@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ=="], | ||
| 2173 | |||
| 2174 | "metro-resolver": ["metro-resolver@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A=="], | ||
| 2175 | |||
| 2176 | "metro-runtime": ["metro-runtime@0.84.4", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q=="], | ||
| 2177 | |||
| 2178 | "metro-source-map": ["metro-source-map@0.84.4", "", { "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.84.4", "nullthrows": "^1.1.1", "ob1": "0.84.4", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g=="], | ||
| 2179 | |||
| 2180 | "metro-symbolicate": ["metro-symbolicate@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.84.4", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA=="], | ||
| 2181 | |||
| 2182 | "metro-transform-plugins": ["metro-transform-plugins@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow=="], | ||
| 2183 | |||
| 2184 | "metro-transform-worker": ["metro-transform-worker@0.84.4", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "metro": "0.84.4", "metro-babel-transformer": "0.84.4", "metro-cache": "0.84.4", "metro-cache-key": "0.84.4", "metro-minify-terser": "0.84.4", "metro-source-map": "0.84.4", "metro-transform-plugins": "0.84.4", "nullthrows": "^1.1.1" } }, "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg=="], | ||
| 2185 | |||
| 1550 | "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], | 2186 | "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], |
| 1551 | 2187 | ||
| 1552 | "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], | 2188 | "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], |
| ... | @@ -1603,14 +2239,26 @@ | ... | @@ -1603,14 +2239,26 @@ |
| 1603 | 2239 | ||
| 1604 | "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], | 2240 | "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], |
| 1605 | 2241 | ||
| 2242 | "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], | ||
| 2243 | |||
| 1606 | "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], | 2244 | "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], |
| 1607 | 2245 | ||
| 1608 | "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], | 2246 | "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], |
| 1609 | 2247 | ||
| 1610 | "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], | 2248 | "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], |
| 2249 | |||
| 2250 | "mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], | ||
| 1611 | 2251 | ||
| 1612 | "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], | 2252 | "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], |
| 1613 | 2253 | ||
| 2254 | "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], | ||
| 2255 | |||
| 2256 | "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], | ||
| 2257 | |||
| 2258 | "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], | ||
| 2259 | |||
| 2260 | "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], | ||
| 2261 | |||
| 1614 | "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], | 2262 | "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], |
| 1615 | 2263 | ||
| 1616 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], | 2264 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], |
| ... | @@ -1625,11 +2273,13 @@ | ... | @@ -1625,11 +2273,13 @@ |
| 1625 | 2273 | ||
| 1626 | "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], | 2274 | "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], |
| 1627 | 2275 | ||
| 2276 | "multitars": ["multitars@1.0.0", "", {}, "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg=="], | ||
| 2277 | |||
| 1628 | "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], | 2278 | "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], |
| 1629 | 2279 | ||
| 1630 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], | 2280 | "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], |
| 1631 | 2281 | ||
| 1632 | "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], | 2282 | "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], |
| 1633 | 2283 | ||
| 1634 | "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], | 2284 | "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], |
| 1635 | 2285 | ||
| ... | @@ -1639,8 +2289,12 @@ | ... | @@ -1639,8 +2289,12 @@ |
| 1639 | 2289 | ||
| 1640 | "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], | 2290 | "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], |
| 1641 | 2291 | ||
| 2292 | "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], | ||
| 2293 | |||
| 1642 | "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], | 2294 | "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], |
| 1643 | 2295 | ||
| 2296 | "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], | ||
| 2297 | |||
| 1644 | "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], | 2298 | "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], |
| 1645 | 2299 | ||
| 1646 | "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], | 2300 | "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], |
| ... | @@ -1651,8 +2305,14 @@ | ... | @@ -1651,8 +2305,14 @@ |
| 1651 | 2305 | ||
| 1652 | "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], | 2306 | "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], |
| 1653 | 2307 | ||
| 2308 | "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], | ||
| 2309 | |||
| 1654 | "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], | 2310 | "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], |
| 1655 | 2311 | ||
| 2312 | "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], | ||
| 2313 | |||
| 2314 | "ob1": ["ob1@0.84.4", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA=="], | ||
| 2315 | |||
| 1656 | "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], | 2316 | "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], |
| 1657 | 2317 | ||
| 1658 | "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], | 2318 | "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], |
| ... | @@ -1667,12 +2327,20 @@ | ... | @@ -1667,12 +2327,20 @@ |
| 1667 | 2327 | ||
| 1668 | "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], | 2328 | "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], |
| 1669 | 2329 | ||
| 2330 | "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], | ||
| 2331 | |||
| 1670 | "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], | 2332 | "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], |
| 1671 | 2333 | ||
| 2334 | "onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="], | ||
| 2335 | |||
| 1672 | "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], | 2336 | "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], |
| 1673 | 2337 | ||
| 1674 | "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], | 2338 | "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], |
| 1675 | 2339 | ||
| 2340 | "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], | ||
| 2341 | |||
| 2342 | "ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], | ||
| 2343 | |||
| 1676 | "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], | 2344 | "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], |
| 1677 | 2345 | ||
| 1678 | "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="], | 2346 | "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="], |
| ... | @@ -1693,6 +2361,8 @@ | ... | @@ -1693,6 +2361,8 @@ |
| 1693 | 2361 | ||
| 1694 | "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], | 2362 | "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], |
| 1695 | 2363 | ||
| 2364 | "parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="], | ||
| 2365 | |||
| 1696 | "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], | 2366 | "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], |
| 1697 | 2367 | ||
| 1698 | "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], | 2368 | "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], |
| ... | @@ -1701,6 +2371,10 @@ | ... | @@ -1701,6 +2371,10 @@ |
| 1701 | 2371 | ||
| 1702 | "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], | 2372 | "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], |
| 1703 | 2373 | ||
| 2374 | "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], | ||
| 2375 | |||
| 2376 | "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], | ||
| 2377 | |||
| 1704 | "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], | 2378 | "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], |
| 1705 | 2379 | ||
| 1706 | "path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], | 2380 | "path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], |
| ... | @@ -1721,6 +2395,8 @@ | ... | @@ -1721,6 +2395,8 @@ |
| 1721 | 2395 | ||
| 1722 | "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], | 2396 | "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], |
| 1723 | 2397 | ||
| 2398 | "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], | ||
| 2399 | |||
| 1724 | "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], | 2400 | "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], |
| 1725 | 2401 | ||
| 1726 | "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], | 2402 | "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], |
| ... | @@ -1729,10 +2405,18 @@ | ... | @@ -1729,10 +2405,18 @@ |
| 1729 | 2405 | ||
| 1730 | "pretty-cache-header": ["pretty-cache-header@1.0.0", "", { "dependencies": { "timestring": "^6.0.0" } }, "sha512-xtXazslu25CdnGnUkByU1RoOjK55TqwatJkjjJLg5ZAdz2Lngko/mmaUgeET36P2GMlNwh3fdM7FWBO717pNcw=="], | 2406 | "pretty-cache-header": ["pretty-cache-header@1.0.0", "", { "dependencies": { "timestring": "^6.0.0" } }, "sha512-xtXazslu25CdnGnUkByU1RoOjK55TqwatJkjjJLg5ZAdz2Lngko/mmaUgeET36P2GMlNwh3fdM7FWBO717pNcw=="], |
| 1731 | 2407 | ||
| 2408 | "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], | ||
| 2409 | |||
| 1732 | "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], | 2410 | "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], |
| 1733 | 2411 | ||
| 2412 | "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], | ||
| 2413 | |||
| 1734 | "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], | 2414 | "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], |
| 1735 | 2415 | ||
| 2416 | "promise": ["promise@8.3.0", "", { "dependencies": { "asap": "~2.0.6" } }, "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg=="], | ||
| 2417 | |||
| 2418 | "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], | ||
| 2419 | |||
| 1736 | "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], | 2420 | "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], |
| 1737 | 2421 | ||
| 1738 | "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], | 2422 | "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], |
| ... | @@ -1747,6 +2431,10 @@ | ... | @@ -1747,6 +2431,10 @@ |
| 1747 | 2431 | ||
| 1748 | "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], | 2432 | "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], |
| 1749 | 2433 | ||
| 2434 | "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], | ||
| 2435 | |||
| 2436 | "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], | ||
| 2437 | |||
| 1750 | "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], | 2438 | "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], |
| 1751 | 2439 | ||
| 1752 | "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], | 2440 | "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], |
| ... | @@ -1755,28 +2443,86 @@ | ... | @@ -1755,28 +2443,86 @@ |
| 1755 | 2443 | ||
| 1756 | "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], | 2444 | "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], |
| 1757 | 2445 | ||
| 1758 | "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], | 2446 | "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], |
| 1759 | 2447 | ||
| 1760 | "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], | 2448 | "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], |
| 2449 | |||
| 2450 | "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], | ||
| 1761 | 2451 | ||
| 1762 | "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], | 2452 | "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], |
| 1763 | 2453 | ||
| 2454 | "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], | ||
| 2455 | |||
| 2456 | "react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="], | ||
| 2457 | |||
| 2458 | "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], | ||
| 2459 | |||
| 1764 | "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], | 2460 | "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], |
| 1765 | 2461 | ||
| 2462 | "react-native": ["react-native@0.85.3", "", { "dependencies": { "@react-native/assets-registry": "0.85.3", "@react-native/codegen": "0.85.3", "@react-native/community-cli-plugin": "0.85.3", "@react-native/gradle-plugin": "0.85.3", "@react-native/js-polyfills": "0.85.3", "@react-native/normalize-colors": "0.85.3", "@react-native/virtualized-lists": "0.85.3", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.33.3", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.10", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.85.3", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA=="], | ||
| 2463 | |||
| 2464 | "react-native-drawer-layout": ["react-native-drawer-layout@4.2.4", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-gesture-handler": ">= 2.0.0", "react-native-reanimated": ">= 2.0.0" } }, "sha512-l1Le5HcVidobnJm8xqFZo46Rs8FDHdxbTZhkjxpNSRgU+QMoQXilOfzTHAeNjEGiKVGgIs9cW3ctXeHqgp5jJg=="], | ||
| 2465 | |||
| 2466 | "react-native-gesture-handler": ["react-native-gesture-handler@2.31.2", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A=="], | ||
| 2467 | |||
| 2468 | "react-native-image-viewing": ["react-native-image-viewing@0.2.2", "", { "peerDependencies": { "react": ">=16.11.0", "react-native": ">=0.61.3" } }, "sha512-osWieG+p/d2NPbAyonOMubttajtYEYiRGQaJA54slFxZ69j1V4/dCmcrVQry47ktVKy8/qpFwCpW1eT6MH5T2Q=="], | ||
| 2469 | |||
| 2470 | "react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="], | ||
| 2471 | |||
| 2472 | "react-native-keyboard-controller": ["react-native-keyboard-controller@1.21.6", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-reanimated": ">=3.0.0" } }, "sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw=="], | ||
| 2473 | |||
| 2474 | "react-native-nitro-markdown": ["react-native-nitro-markdown@0.5.8", "", { "peerDependencies": { "react": "*", "react-native": ">=0.75.0", "react-native-mathjax-svg": ">=0.9.0", "react-native-nitro-modules": ">=0.35.5", "react-native-svg": ">=13.0.0" }, "optionalPeers": ["react-native-mathjax-svg", "react-native-svg"] }, "sha512-bBKmdWgMqZoNozuHzJgc8VxWRSjLnrHAK0bRRvz7gj5FqPxLJz+KhRV0VYZDZgvsR3S8hnmmeEqluTRBTRw+yA=="], | ||
| 2475 | |||
| 2476 | "react-native-nitro-modules": ["react-native-nitro-modules@0.35.7", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-3EiU27EmnxTlD3pAXR2OBWeDg7+9tdjKrNQOPX08rFX5hJPNIT/h1i2PjvTUieOypCGTEi9nW/SNBtK3F+4HYg=="], | ||
| 2477 | |||
| 2478 | "react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="], | ||
| 2479 | |||
| 2480 | "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], | ||
| 2481 | |||
| 2482 | "react-native-screens": ["react-native-screens@4.25.2", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.82.0" } }, "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg=="], | ||
| 2483 | |||
| 2484 | "react-native-shiki-engine": ["react-native-shiki-engine@0.3.10", "", { "dependencies": { "@shikijs/types": "^3.19.0", "@shikijs/vscode-textmate": "^10.0.2" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-gaEFPbd3//nFCApZOj1c+4Q69c1aqFYYn0v22YRW4jRVerf2MmcAWXYnRnmkJIxJ6hXy8U2JsBEAX6HhsCipzA=="], | ||
| 2485 | |||
| 2486 | "react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="], | ||
| 2487 | |||
| 2488 | "react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="], | ||
| 2489 | |||
| 2490 | "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], | ||
| 2491 | |||
| 2492 | "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], | ||
| 2493 | |||
| 2494 | "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], | ||
| 2495 | |||
| 2496 | "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], | ||
| 2497 | |||
| 1766 | "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], | 2498 | "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], |
| 1767 | 2499 | ||
| 1768 | "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], | 2500 | "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], |
| 1769 | 2501 | ||
| 2502 | "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], | ||
| 2503 | |||
| 1770 | "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], | 2504 | "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], |
| 1771 | 2505 | ||
| 1772 | "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], | 2506 | "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], |
| 1773 | 2507 | ||
| 2508 | "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], | ||
| 2509 | |||
| 2510 | "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], | ||
| 2511 | |||
| 2512 | "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], | ||
| 2513 | |||
| 1774 | "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], | 2514 | "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], |
| 1775 | 2515 | ||
| 1776 | "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], | 2516 | "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], |
| 1777 | 2517 | ||
| 1778 | "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], | 2518 | "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], |
| 1779 | 2519 | ||
| 2520 | "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], | ||
| 2521 | |||
| 2522 | "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], | ||
| 2523 | |||
| 2524 | "regjsparser": ["regjsparser@0.13.1", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw=="], | ||
| 2525 | |||
| 1780 | "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="], | 2526 | "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="], |
| 1781 | 2527 | ||
| 1782 | "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], | 2528 | "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], |
| ... | @@ -1803,12 +2549,20 @@ | ... | @@ -1803,12 +2549,20 @@ |
| 1803 | 2549 | ||
| 1804 | "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], | 2550 | "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], |
| 1805 | 2551 | ||
| 2552 | "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], | ||
| 2553 | |||
| 1806 | "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], | 2554 | "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], |
| 1807 | 2555 | ||
| 2556 | "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], | ||
| 2557 | |||
| 1808 | "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], | 2558 | "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], |
| 1809 | 2559 | ||
| 2560 | "resolve-workspace-root": ["resolve-workspace-root@2.0.1", "", {}, "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w=="], | ||
| 2561 | |||
| 1810 | "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], | 2562 | "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], |
| 1811 | 2563 | ||
| 2564 | "restore-cursor": ["restore-cursor@2.0.0", "", { "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" } }, "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q=="], | ||
| 2565 | |||
| 1812 | "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], | 2566 | "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], |
| 1813 | 2567 | ||
| 1814 | "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="], | 2568 | "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="], |
| ... | @@ -1827,6 +2581,8 @@ | ... | @@ -1827,6 +2581,8 @@ |
| 1827 | 2581 | ||
| 1828 | "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], | 2582 | "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], |
| 1829 | 2583 | ||
| 2584 | "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], | ||
| 2585 | |||
| 1830 | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], | 2586 | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], |
| 1831 | 2587 | ||
| 1832 | "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], | 2588 | "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], |
| ... | @@ -1837,7 +2593,7 @@ | ... | @@ -1837,7 +2593,7 @@ |
| 1837 | 2593 | ||
| 1838 | "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], | 2594 | "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], |
| 1839 | 2595 | ||
| 1840 | "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], | 2596 | "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], |
| 1841 | 2597 | ||
| 1842 | "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], | 2598 | "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], |
| 1843 | 2599 | ||
| ... | @@ -1845,17 +2601,25 @@ | ... | @@ -1845,17 +2601,25 @@ |
| 1845 | 2601 | ||
| 1846 | "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], | 2602 | "seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="], |
| 1847 | 2603 | ||
| 1848 | "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], | 2604 | "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], |
| 2605 | |||
| 2606 | "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], | ||
| 1849 | 2607 | ||
| 1850 | "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], | 2608 | "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], |
| 1851 | 2609 | ||
| 2610 | "sf-symbols-typescript": ["sf-symbols-typescript@2.2.0", "", {}, "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw=="], | ||
| 2611 | |||
| 2612 | "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], | ||
| 2613 | |||
| 1852 | "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], | 2614 | "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], |
| 1853 | 2615 | ||
| 1854 | "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], | 2616 | "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], |
| 1855 | 2617 | ||
| 1856 | "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], | 2618 | "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], |
| 1857 | 2619 | ||
| 1858 | "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], | 2620 | "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], |
| 2621 | |||
| 2622 | "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], | ||
| 1859 | 2623 | ||
| 1860 | "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], | 2624 | "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], |
| 1861 | 2625 | ||
| ... | @@ -1869,42 +2633,70 @@ | ... | @@ -1869,42 +2633,70 @@ |
| 1869 | 2633 | ||
| 1870 | "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], | 2634 | "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], |
| 1871 | 2635 | ||
| 2636 | "simple-plist": ["simple-plist@1.3.1", "", { "dependencies": { "bplist-creator": "0.1.0", "bplist-parser": "0.3.1", "plist": "^3.0.5" } }, "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw=="], | ||
| 2637 | |||
| 2638 | "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], | ||
| 2639 | |||
| 1872 | "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], | 2640 | "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], |
| 1873 | 2641 | ||
| 1874 | "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], | 2642 | "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], |
| 1875 | 2643 | ||
| 2644 | "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], | ||
| 2645 | |||
| 1876 | "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], | 2646 | "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], |
| 1877 | 2647 | ||
| 1878 | "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], | 2648 | "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], |
| 1879 | 2649 | ||
| 1880 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], | 2650 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], |
| 1881 | 2651 | ||
| 2652 | "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], | ||
| 2653 | |||
| 1882 | "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], | 2654 | "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], |
| 1883 | 2655 | ||
| 2656 | "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="], | ||
| 2657 | |||
| 1884 | "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], | 2658 | "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], |
| 1885 | 2659 | ||
| 1886 | "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], | 2660 | "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], |
| 1887 | 2661 | ||
| 2662 | "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], | ||
| 2663 | |||
| 2664 | "stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="], | ||
| 2665 | |||
| 1888 | "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], | 2666 | "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], |
| 1889 | 2667 | ||
| 1890 | "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], | 2668 | "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], |
| 1891 | 2669 | ||
| 1892 | "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], | 2670 | "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], |
| 1893 | 2671 | ||
| 2672 | "stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="], | ||
| 2673 | |||
| 1894 | "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], | 2674 | "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], |
| 1895 | 2675 | ||
| 2676 | "strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="], | ||
| 2677 | |||
| 1896 | "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], | 2678 | "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], |
| 1897 | 2679 | ||
| 1898 | "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], | 2680 | "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], |
| 1899 | 2681 | ||
| 1900 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], | 2682 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], |
| 1901 | 2683 | ||
| 2684 | "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], | ||
| 2685 | |||
| 2686 | "structured-headers": ["structured-headers@0.4.1", "", {}, "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg=="], | ||
| 2687 | |||
| 1902 | "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], | 2688 | "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], |
| 1903 | 2689 | ||
| 1904 | "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], | 2690 | "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], |
| 1905 | 2691 | ||
| 1906 | "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], | 2692 | "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], |
| 1907 | 2693 | ||
| 2694 | "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], | ||
| 2695 | |||
| 2696 | "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], | ||
| 2697 | |||
| 2698 | "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], | ||
| 2699 | |||
| 1908 | "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], | 2700 | "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], |
| 1909 | 2701 | ||
| 1910 | "t3": ["t3@workspace:apps/server"], | 2702 | "t3": ["t3@workspace:apps/server"], |
| ... | @@ -1919,6 +2711,12 @@ | ... | @@ -1919,6 +2711,12 @@ |
| 1919 | 2711 | ||
| 1920 | "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], | 2712 | "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], |
| 1921 | 2713 | ||
| 2714 | "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], | ||
| 2715 | |||
| 2716 | "terser": ["terser@5.47.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw=="], | ||
| 2717 | |||
| 2718 | "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], | ||
| 2719 | |||
| 1922 | "timestring": ["timestring@6.0.0", "", {}, "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA=="], | 2720 | "timestring": ["timestring@6.0.0", "", {}, "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA=="], |
| 1923 | 2721 | ||
| 1924 | "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], | 2722 | "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], |
| ... | @@ -1945,12 +2743,16 @@ | ... | @@ -1945,12 +2743,16 @@ |
| 1945 | 2743 | ||
| 1946 | "tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="], | 2744 | "tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="], |
| 1947 | 2745 | ||
| 2746 | "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], | ||
| 2747 | |||
| 1948 | "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], | 2748 | "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], |
| 1949 | 2749 | ||
| 1950 | "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], | 2750 | "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], |
| 1951 | 2751 | ||
| 1952 | "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], | 2752 | "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], |
| 1953 | 2753 | ||
| 2754 | "toqr": ["toqr@0.1.1", "", {}, "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA=="], | ||
| 2755 | |||
| 1954 | "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], | 2756 | "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], |
| 1955 | 2757 | ||
| 1956 | "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], | 2758 | "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], |
| ... | @@ -2007,6 +2809,14 @@ | ... | @@ -2007,6 +2809,14 @@ |
| 2007 | 2809 | ||
| 2008 | "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], | 2810 | "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], |
| 2009 | 2811 | ||
| 2812 | "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], | ||
| 2813 | |||
| 2814 | "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], | ||
| 2815 | |||
| 2816 | "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], | ||
| 2817 | |||
| 2818 | "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], | ||
| 2819 | |||
| 2010 | "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], | 2820 | "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], |
| 2011 | 2821 | ||
| 2012 | "unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], | 2822 | "unifont": ["unifont@0.7.4", "", { "dependencies": { "css-tree": "^3.1.0", "ofetch": "^1.5.1", "ohash": "^2.0.11" } }, "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg=="], |
| ... | @@ -2031,6 +2841,8 @@ | ... | @@ -2031,6 +2841,8 @@ |
| 2031 | 2841 | ||
| 2032 | "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], | 2842 | "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], |
| 2033 | 2843 | ||
| 2844 | "uniwind": ["uniwind@1.6.5", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "culori": "4.0.2", "lightningcss": "1.30.1" }, "peerDependencies": { "react": ">=19.0.0", "react-native": ">=0.81.0", "tailwindcss": ">=4" } }, "sha512-KjOtZzC+YW0I7+TogyLFGexsKCy2A3ZRVS70Cs5Exs+wyY2ll7Zd22nXywr1w/Jo0eryCie+ekX9ZPdZheb1kw=="], | ||
| 2845 | |||
| 2034 | "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], | 2846 | "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], |
| 2035 | 2847 | ||
| 2036 | "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], | 2848 | "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], |
| ... | @@ -2045,12 +2857,24 @@ | ... | @@ -2045,12 +2857,24 @@ |
| 2045 | 2857 | ||
| 2046 | "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], | 2858 | "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], |
| 2047 | 2859 | ||
| 2860 | "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], | ||
| 2861 | |||
| 2862 | "use-latest-callback": ["use-latest-callback@0.2.6", "", { "peerDependencies": { "react": ">=16.8" } }, "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg=="], | ||
| 2863 | |||
| 2864 | "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], | ||
| 2865 | |||
| 2048 | "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], | 2866 | "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], |
| 2049 | 2867 | ||
| 2868 | "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], | ||
| 2869 | |||
| 2050 | "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], | 2870 | "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], |
| 2051 | 2871 | ||
| 2872 | "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], | ||
| 2873 | |||
| 2052 | "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], | 2874 | "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], |
| 2053 | 2875 | ||
| 2876 | "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], | ||
| 2877 | |||
| 2054 | "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], | 2878 | "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], |
| 2055 | 2879 | ||
| 2056 | "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], | 2880 | "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], |
| ... | @@ -2065,6 +2889,8 @@ | ... | @@ -2065,6 +2889,8 @@ |
| 2065 | 2889 | ||
| 2066 | "vitest-browser-react": ["vitest-browser-react@2.1.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/cOVQ+dZojhavfsbHjcfzB3zrUxG39HIbGdvK9vSBdGc8b8HRu5Bql0p8aXtKw4sb8/E8n5XEncQxvqHtfjjag=="], | 2890 | "vitest-browser-react": ["vitest-browser-react@2.1.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/cOVQ+dZojhavfsbHjcfzB3zrUxG39HIbGdvK9vSBdGc8b8HRu5Bql0p8aXtKw4sb8/E8n5XEncQxvqHtfjjag=="], |
| 2067 | 2891 | ||
| 2892 | "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], | ||
| 2893 | |||
| 2068 | "volar-service-css": ["volar-service-css@0.0.70", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw=="], | 2894 | "volar-service-css": ["volar-service-css@0.0.70", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw=="], |
| 2069 | 2895 | ||
| 2070 | "volar-service-emmet": ["volar-service-emmet@0.0.70", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg=="], | 2896 | "volar-service-emmet": ["volar-service-emmet@0.0.70", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg=="], |
| ... | @@ -2099,10 +2925,20 @@ | ... | @@ -2099,10 +2925,20 @@ |
| 2099 | 2925 | ||
| 2100 | "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], | 2926 | "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], |
| 2101 | 2927 | ||
| 2928 | "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], | ||
| 2929 | |||
| 2930 | "warn-once": ["warn-once@0.1.1", "", {}, "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q=="], | ||
| 2931 | |||
| 2932 | "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], | ||
| 2933 | |||
| 2102 | "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], | 2934 | "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], |
| 2103 | 2935 | ||
| 2104 | "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], | 2936 | "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], |
| 2105 | 2937 | ||
| 2938 | "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], | ||
| 2939 | |||
| 2940 | "whatwg-url-minimum": ["whatwg-url-minimum@0.1.2", "", {}, "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A=="], | ||
| 2941 | |||
| 2106 | "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], | 2942 | "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], |
| 2107 | 2943 | ||
| 2108 | "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], | 2944 | "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], |
| ... | @@ -2113,7 +2949,13 @@ | ... | @@ -2113,7 +2949,13 @@ |
| 2113 | 2949 | ||
| 2114 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], | 2950 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], |
| 2115 | 2951 | ||
| 2116 | "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], | 2952 | "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], |
| 2953 | |||
| 2954 | "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], | ||
| 2955 | |||
| 2956 | "xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="], | ||
| 2957 | |||
| 2958 | "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], | ||
| 2117 | 2959 | ||
| 2118 | "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], | 2960 | "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], |
| 2119 | 2961 | ||
| ... | @@ -2145,49 +2987,85 @@ | ... | @@ -2145,49 +2987,85 @@ |
| 2145 | 2987 | ||
| 2146 | "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], | 2988 | "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], |
| 2147 | 2989 | ||
| 2990 | "zxing-wasm": ["zxing-wasm@3.0.3", "", { "dependencies": { "@types/emscripten": "^1.41.5", "type-fest": "^5.6.0" } }, "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ=="], | ||
| 2991 | |||
| 2148 | "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], | 2992 | "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], |
| 2149 | 2993 | ||
| 2994 | "@astrojs/markdown-remark/shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], | ||
| 2995 | |||
| 2150 | "@astrojs/yaml2ts/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], | 2996 | "@astrojs/yaml2ts/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], |
| 2151 | 2997 | ||
| 2152 | "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], | 2998 | "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], |
| 2153 | 2999 | ||
| 2154 | "@babel/core/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], | ||
| 2155 | |||
| 2156 | "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | 3000 | "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], |
| 2157 | 3001 | ||
| 2158 | "@babel/generator/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], | ||
| 2159 | |||
| 2160 | "@babel/generator/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], | ||
| 2161 | |||
| 2162 | "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], | 3002 | "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], |
| 2163 | 3003 | ||
| 2164 | "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | 3004 | "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], |
| 2165 | 3005 | ||
| 3006 | "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | ||
| 3007 | |||
| 3008 | "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | ||
| 3009 | |||
| 2166 | "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], | 3010 | "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], |
| 2167 | 3011 | ||
| 2168 | "@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], | 3012 | "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], |
| 2169 | 3013 | ||
| 2170 | "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], | 3014 | "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], |
| 2171 | 3015 | ||
| 2172 | "@base-ui/react/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], | 3016 | "@effect/atom-react/react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], |
| 2173 | 3017 | ||
| 2174 | "@base-ui/utils/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], | 3018 | "@effect/platform-node-shared/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], |
| 2175 | 3019 | ||
| 2176 | "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], | 3020 | "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], |
| 2177 | 3021 | ||
| 2178 | "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | 3022 | "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], |
| 2179 | 3023 | ||
| 3024 | "@expo/cli/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], | ||
| 3025 | |||
| 3026 | "@expo/cli/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], | ||
| 3027 | |||
| 3028 | "@expo/cli/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], | ||
| 3029 | |||
| 3030 | "@expo/cli/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], | ||
| 3031 | |||
| 3032 | "@expo/cli/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], | ||
| 3033 | |||
| 3034 | "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], | ||
| 3035 | |||
| 3036 | "@expo/fingerprint/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], | ||
| 3037 | |||
| 3038 | "@expo/metro-config/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], | ||
| 3039 | |||
| 3040 | "@expo/metro-config/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], | ||
| 3041 | |||
| 3042 | "@expo/metro-config/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], | ||
| 3043 | |||
| 3044 | "@expo/metro-runtime/@expo/log-box": ["@expo/log-box@55.0.12", "", { "dependencies": { "@expo/dom-webview": "^55.0.6", "anser": "^1.4.9", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-f9ARS8J60cq3LLNdIqmUjYwyerBzVS5Ecp7KjIf3GOIPjW0571rkcwLz4/U18l/1DeSkSzIkYsNl2TC9oTdWaQ=="], | ||
| 3045 | |||
| 3046 | "@expo/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], | ||
| 3047 | |||
| 2180 | "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], | 3048 | "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], |
| 2181 | 3049 | ||
| 2182 | "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], | 3050 | "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], |
| 3051 | |||
| 3052 | "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], | ||
| 3053 | |||
| 3054 | "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], | ||
| 2183 | 3055 | ||
| 2184 | "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], | 3056 | "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], |
| 2185 | 3057 | ||
| 2186 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], | 3058 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], |
| 2187 | 3059 | ||
| 2188 | "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], | 3060 | "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], |
| 3061 | |||
| 3062 | "@t3tools/mobile/@t3tools/mobile-review-diff-native": ["@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff", {}], | ||
| 3063 | |||
| 3064 | "@t3tools/mobile/@t3tools/mobile-terminal-native": ["@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal", {}], | ||
| 2189 | 3065 | ||
| 2190 | "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], | 3066 | "@t3tools/web/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], |
| 3067 | |||
| 3068 | "@t3tools/web/react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], | ||
| 2191 | 3069 | ||
| 2192 | "@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], | 3070 | "@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], |
| 2193 | 3071 | ||
| ... | @@ -2209,13 +3087,19 @@ | ... | @@ -2209,13 +3087,19 @@ |
| 2209 | 3087 | ||
| 2210 | "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], | 3088 | "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], |
| 2211 | 3089 | ||
| 3090 | "@tanstack/router-generator/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], | ||
| 3091 | |||
| 2212 | "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], | 3092 | "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| 2213 | 3093 | ||
| 2214 | "@tanstack/router-plugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], | 3094 | "@tanstack/router-plugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], |
| 2215 | 3095 | ||
| 2216 | "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], | 3096 | "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| 2217 | 3097 | ||
| 2218 | "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], | 3098 | "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], |
| 3099 | |||
| 3100 | "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], | ||
| 3101 | |||
| 3102 | "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], | ||
| 2219 | 3103 | ||
| 2220 | "@vercel/config/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], | 3104 | "@vercel/config/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], |
| 2221 | 3105 | ||
| ... | @@ -2225,73 +3109,225 @@ | ... | @@ -2225,73 +3109,225 @@ |
| 2225 | 3109 | ||
| 2226 | "@vitest/browser/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], | 3110 | "@vitest/browser/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], |
| 2227 | 3111 | ||
| 3112 | "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], | ||
| 3113 | |||
| 2228 | "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | 3114 | "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], |
| 2229 | 3115 | ||
| 2230 | "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], | 3116 | "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], |
| 2231 | 3117 | ||
| 3118 | "astro/shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], | ||
| 3119 | |||
| 3120 | "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], | ||
| 3121 | |||
| 3122 | "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], | ||
| 3123 | |||
| 3124 | "chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], | ||
| 3125 | |||
| 3126 | "chromium-edge-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], | ||
| 3127 | |||
| 2232 | "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], | 3128 | "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], |
| 2233 | 3129 | ||
| 3130 | "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], | ||
| 3131 | |||
| 3132 | "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], | ||
| 3133 | |||
| 3134 | "compression/negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], | ||
| 3135 | |||
| 3136 | "connect/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], | ||
| 3137 | |||
| 3138 | "css-tree/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], | ||
| 3139 | |||
| 2234 | "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], | 3140 | "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], |
| 2235 | 3141 | ||
| 2236 | "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], | 3142 | "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], |
| 2237 | 3143 | ||
| 3144 | "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], | ||
| 3145 | |||
| 3146 | "expo-router/@expo/metro-runtime": ["@expo/metro-runtime@56.0.13", "", { "dependencies": { "@expo/log-box": "^56.0.12", "anser": "^1.4.9", "pretty-format": "^29.7.0", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom"] }, "sha512-aMaFa/RPYm2iQoyYOB5q8AxDmWvf4E2yFbZ6rmBIQWaIPDdixGVUlLQeV8DlDAfZ/j+pNYO7l5M+774WbgkTgg=="], | ||
| 3147 | |||
| 3148 | "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], | ||
| 3149 | |||
| 2238 | "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], | 3150 | "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], |
| 2239 | 3151 | ||
| 3152 | "express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], | ||
| 3153 | |||
| 3154 | "express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], | ||
| 3155 | |||
| 3156 | "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], | ||
| 3157 | |||
| 3158 | "express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], | ||
| 3159 | |||
| 3160 | "express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], | ||
| 3161 | |||
| 3162 | "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], | ||
| 3163 | |||
| 3164 | "finalhandler/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], | ||
| 3165 | |||
| 3166 | "finalhandler/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="], | ||
| 3167 | |||
| 3168 | "finalhandler/statuses": ["statuses@1.5.0", "", {}, "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="], | ||
| 3169 | |||
| 3170 | "get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], | ||
| 3171 | |||
| 2240 | "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], | 3172 | "h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], |
| 2241 | 3173 | ||
| 3174 | "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], | ||
| 3175 | |||
| 3176 | "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], | ||
| 3177 | |||
| 3178 | "jest-util/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], | ||
| 3179 | |||
| 3180 | "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | ||
| 3181 | |||
| 3182 | "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], | ||
| 3183 | |||
| 3184 | "json-schema-to-ts/@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], | ||
| 3185 | |||
| 3186 | "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], | ||
| 3187 | |||
| 3188 | "log-symbols/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], | ||
| 3189 | |||
| 2242 | "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], | 3190 | "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], |
| 2243 | 3191 | ||
| 3192 | "metro/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], | ||
| 3193 | |||
| 3194 | "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], | ||
| 3195 | |||
| 3196 | "metro/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], | ||
| 3197 | |||
| 3198 | "metro/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], | ||
| 3199 | |||
| 3200 | "metro/serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], | ||
| 3201 | |||
| 3202 | "metro-babel-transformer/hermes-parser": ["hermes-parser@0.35.0", "", { "dependencies": { "hermes-estree": "0.35.0" } }, "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA=="], | ||
| 3203 | |||
| 3204 | "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | ||
| 3205 | |||
| 3206 | "open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], | ||
| 3207 | |||
| 3208 | "open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], | ||
| 3209 | |||
| 3210 | "ora/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], | ||
| 3211 | |||
| 3212 | "ora/strip-ansi": ["strip-ansi@5.2.0", "", { "dependencies": { "ansi-regex": "^4.1.0" } }, "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA=="], | ||
| 3213 | |||
| 2244 | "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], | 3214 | "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], |
| 2245 | 3215 | ||
| 3216 | "parse-png/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="], | ||
| 3217 | |||
| 3218 | "plist/@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], | ||
| 3219 | |||
| 3220 | "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], | ||
| 3221 | |||
| 3222 | "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], | ||
| 3223 | |||
| 3224 | "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], | ||
| 3225 | |||
| 3226 | "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], | ||
| 3227 | |||
| 2246 | "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], | 3228 | "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], |
| 2247 | 3229 | ||
| 3230 | "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], | ||
| 3231 | |||
| 2248 | "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], | 3232 | "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], |
| 2249 | 3233 | ||
| 3234 | "rolldown-plugin-dts/@babel/generator": ["@babel/generator@8.0.0-rc.2", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.2", "@babel/types": "^8.0.0-rc.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ=="], | ||
| 3235 | |||
| 2250 | "rolldown-plugin-dts/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], | 3236 | "rolldown-plugin-dts/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], |
| 2251 | 3237 | ||
| 2252 | "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], | 3238 | "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], |
| 2253 | 3239 | ||
| 2254 | "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], | 3240 | "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], |
| 2255 | 3241 | ||
| 3242 | "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], | ||
| 3243 | |||
| 3244 | "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], | ||
| 3245 | |||
| 2256 | "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], | 3246 | "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], |
| 2257 | 3247 | ||
| 3248 | "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], | ||
| 3249 | |||
| 3250 | "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], | ||
| 3251 | |||
| 3252 | "stacktrace-parser/type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="], | ||
| 3253 | |||
| 3254 | "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], | ||
| 3255 | |||
| 3256 | "svgo/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], | ||
| 3257 | |||
| 2258 | "t3/@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="], | 3258 | "t3/@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="], |
| 2259 | 3259 | ||
| 3260 | "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], | ||
| 3261 | |||
| 2260 | "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], | 3262 | "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], |
| 2261 | 3263 | ||
| 3264 | "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], | ||
| 3265 | |||
| 3266 | "unifont/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], | ||
| 3267 | |||
| 2262 | "unrun/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], | 3268 | "unrun/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], |
| 2263 | 3269 | ||
| 2264 | "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], | 3270 | "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], |
| 2265 | 3271 | ||
| 2266 | "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], | 3272 | "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], |
| 2267 | 3273 | ||
| 3274 | "vite/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], | ||
| 3275 | |||
| 2268 | "vite/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], | 3276 | "vite/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], |
| 2269 | 3277 | ||
| 2270 | "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], | 3278 | "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], |
| 2271 | 3279 | ||
| 3280 | "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], | ||
| 3281 | |||
| 3282 | "xcode/uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], | ||
| 3283 | |||
| 2272 | "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], | 3284 | "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], |
| 2273 | 3285 | ||
| 2274 | "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], | 3286 | "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], |
| 2275 | 3287 | ||
| 2276 | "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], | 3288 | "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], |
| 2277 | 3289 | ||
| 2278 | "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], | 3290 | "zxing-wasm/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], |
| 3291 | |||
| 3292 | "@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], | ||
| 3293 | |||
| 3294 | "@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], | ||
| 3295 | |||
| 3296 | "@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], | ||
| 3297 | |||
| 3298 | "@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], | ||
| 3299 | |||
| 3300 | "@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], | ||
| 3301 | |||
| 3302 | "@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | ||
| 2279 | 3303 | ||
| 2280 | "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], | 3304 | "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], |
| 2281 | 3305 | ||
| 2282 | "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], | 3306 | "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], |
| 2283 | 3307 | ||
| 2284 | "@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], | 3308 | "@expo/metro-config/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], |
| 3309 | |||
| 3310 | "@expo/metro-config/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], | ||
| 3311 | |||
| 3312 | "@expo/metro-config/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], | ||
| 3313 | |||
| 3314 | "@expo/metro-config/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], | ||
| 3315 | |||
| 3316 | "@expo/metro-config/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], | ||
| 3317 | |||
| 3318 | "@expo/metro-config/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], | ||
| 3319 | |||
| 3320 | "@expo/metro-config/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], | ||
| 2285 | 3321 | ||
| 2286 | "@pierre/diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], | 3322 | "@expo/metro-config/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], |
| 2287 | 3323 | ||
| 2288 | "@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], | 3324 | "@expo/metro-config/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], |
| 2289 | 3325 | ||
| 2290 | "@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], | 3326 | "@expo/metro-config/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], |
| 2291 | 3327 | ||
| 2292 | "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], | 3328 | "@expo/metro-runtime/@expo/log-box/@expo/dom-webview": ["@expo/dom-webview@55.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZNm8tiNEZysxrr36J0x4mOCGyJDcaIvL/3tMxBz0VJIJDcV19xjuJAhJQxHovu+jKx6s9tRyEAINa1mdrzV39g=="], |
| 2293 | 3329 | ||
| 2294 | "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], | 3330 | "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], |
| 2295 | 3331 | ||
| 2296 | "@rolldown/plugin-babel/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], | 3332 | "@rolldown/plugin-babel/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], |
| 2297 | 3333 | ||
| ... | @@ -2349,14 +3385,74 @@ | ... | @@ -2349,14 +3385,74 @@ |
| 2349 | 3385 | ||
| 2350 | "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], | 3386 | "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], |
| 2351 | 3387 | ||
| 3388 | "@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], | ||
| 3389 | |||
| 2352 | "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], | 3390 | "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], |
| 2353 | 3391 | ||
| 2354 | "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], | 3392 | "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], |
| 2355 | 3393 | ||
| 3394 | "astro/shiki/@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], | ||
| 3395 | |||
| 3396 | "astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], | ||
| 3397 | |||
| 3398 | "astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], | ||
| 3399 | |||
| 3400 | "astro/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], | ||
| 3401 | |||
| 3402 | "astro/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], | ||
| 3403 | |||
| 3404 | "astro/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], | ||
| 3405 | |||
| 3406 | "chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], | ||
| 3407 | |||
| 3408 | "chromium-edge-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], | ||
| 3409 | |||
| 3410 | "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], | ||
| 3411 | |||
| 3412 | "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], | ||
| 3413 | |||
| 2356 | "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], | 3414 | "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], |
| 2357 | 3415 | ||
| 3416 | "express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], | ||
| 3417 | |||
| 3418 | "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], | ||
| 3419 | |||
| 3420 | "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], | ||
| 3421 | |||
| 3422 | "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], | ||
| 3423 | |||
| 3424 | "log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], | ||
| 3425 | |||
| 3426 | "log-symbols/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], | ||
| 3427 | |||
| 3428 | "log-symbols/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], | ||
| 3429 | |||
| 3430 | "metro-babel-transformer/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], | ||
| 3431 | |||
| 3432 | "metro/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], | ||
| 3433 | |||
| 3434 | "metro/hermes-parser/hermes-estree": ["hermes-estree@0.35.0", "", {}, "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg=="], | ||
| 3435 | |||
| 3436 | "metro/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], | ||
| 3437 | |||
| 3438 | "ora/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], | ||
| 3439 | |||
| 3440 | "ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], | ||
| 3441 | |||
| 3442 | "ora/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], | ||
| 3443 | |||
| 3444 | "ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="], | ||
| 3445 | |||
| 2358 | "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], | 3446 | "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], |
| 2359 | 3447 | ||
| 3448 | "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], | ||
| 3449 | |||
| 3450 | "svgo/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], | ||
| 3451 | |||
| 3452 | "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], | ||
| 3453 | |||
| 3454 | "unifont/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], | ||
| 3455 | |||
| 2360 | "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], | 3456 | "unrun/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], |
| 2361 | 3457 | ||
| 2362 | "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], | 3458 | "unrun/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], |
| ... | @@ -2389,6 +3485,26 @@ | ... | @@ -2389,6 +3485,26 @@ |
| 2389 | 3485 | ||
| 2390 | "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], | 3486 | "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], |
| 2391 | 3487 | ||
| 3488 | "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], | ||
| 3489 | |||
| 3490 | "vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], | ||
| 3491 | |||
| 3492 | "vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], | ||
| 3493 | |||
| 3494 | "vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], | ||
| 3495 | |||
| 3496 | "vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], | ||
| 3497 | |||
| 3498 | "vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], | ||
| 3499 | |||
| 3500 | "vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], | ||
| 3501 | |||
| 3502 | "vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], | ||
| 3503 | |||
| 3504 | "vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], | ||
| 3505 | |||
| 3506 | "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], | ||
| 3507 | |||
| 2392 | "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], | 3508 | "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="], |
| 2393 | 3509 | ||
| 2394 | "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], | 3510 | "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="], |
| ... | @@ -2422,5 +3538,17 @@ | ... | @@ -2422,5 +3538,17 @@ |
| 2422 | "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | 3538 | "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], |
| 2423 | 3539 | ||
| 2424 | "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], | 3540 | "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], |
| 3541 | |||
| 3542 | "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], | ||
| 3543 | |||
| 3544 | "log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], | ||
| 3545 | |||
| 3546 | "ora/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], | ||
| 3547 | |||
| 3548 | "ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], | ||
| 3549 | |||
| 3550 | "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], | ||
| 3551 | |||
| 3552 | "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], | ||
| 2425 | } | 3553 | } |
| 2426 | } | 3554 | } |
package.json+4-1| ... | @@ -44,6 +44,7 @@ | ... | @@ -44,6 +44,7 @@ |
| 44 | "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3", | 44 | "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3", |
| 45 | "typecheck": "turbo run typecheck", | 45 | "typecheck": "turbo run typecheck", |
| 46 | "lint": "oxlint --report-unused-disable-directives", | 46 | "lint": "oxlint --report-unused-disable-directives", |
| 47 | "lint:mobile": "node scripts/mobile-native-static-check.ts", | ||
| 47 | "test": "turbo run test", | 48 | "test": "turbo run test", |
| 48 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", | 49 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 49 | "fmt": "oxfmt", | 50 | "fmt": "oxfmt", |
| ... | @@ -92,7 +93,9 @@ | ... | @@ -92,7 +93,9 @@ |
| 92 | ] | 93 | ] |
| 93 | }, | 94 | }, |
| 94 | "patchedDependencies": { | 95 | "patchedDependencies": { |
| 95 | "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch" | 96 | "effect@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch", |
| 97 | "react-native-nitro-modules@0.35.4": "patches/react-native-nitro-modules@0.35.4.patch", | ||
| 98 | "@pierre/diffs@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch" | ||
| 96 | }, | 99 | }, |
| 97 | "trustedDependencies": [ | 100 | "trustedDependencies": [ |
| 98 | "node-pty", | 101 | "node-pty", |
packages/client-runtime/package.json+7-1| ... | @@ -2,10 +2,15 @@ | ... | @@ -2,10 +2,15 @@ |
| 2 | "name": "@t3tools/client-runtime", | 2 | "name": "@t3tools/client-runtime", |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "main": "./src/index.ts", | ||
| 6 | "types": "./src/index.ts", | ||
| 5 | "exports": { | 7 | "exports": { |
| 6 | ".": { | 8 | ".": { |
| 7 | "types": "./src/index.ts", | 9 | "types": "./src/index.ts", |
| 8 | "import": "./src/index.ts" | 10 | "react-native": "./src/index.ts", |
| 11 | "import": "./src/index.ts", | ||
| 12 | "require": "./src/index.ts", | ||
| 13 | "default": "./src/index.ts" | ||
| 9 | } | 14 | } |
| 10 | }, | 15 | }, |
| 11 | "scripts": { | 16 | "scripts": { |
| ... | @@ -18,6 +23,7 @@ | ... | @@ -18,6 +23,7 @@ |
| 18 | "effect": "catalog:" | 23 | "effect": "catalog:" |
| 19 | }, | 24 | }, |
| 20 | "devDependencies": { | 25 | "devDependencies": { |
| 26 | "@effect/vitest": "catalog:", | ||
| 21 | "vitest": "catalog:" | 27 | "vitest": "catalog:" |
| 22 | } | 28 | } |
| 23 | } | 29 | } |
packages/client-runtime/src/addProject.test.ts created+146| ... | @@ -0,0 +1,146 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { | ||
| 3 | EnvironmentId, | ||
| 4 | ProjectId, | ||
| 5 | CommandId, | ||
| 6 | SourceControlDiscoveryResult, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | buildAddProjectRemoteSourceReadiness, | ||
| 12 | buildProjectCreateCommand, | ||
| 13 | findExistingAddProject, | ||
| 14 | getAddProjectInitialQuery, | ||
| 15 | resolveAddProjectPath, | ||
| 16 | sortAddProjectProviderSources, | ||
| 17 | } from "./addProject.ts"; | ||
| 18 | import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; | ||
| 19 | |||
| 20 | describe("add project shared logic", () => { | ||
| 21 | it("resolves initial browse paths from settings", () => { | ||
| 22 | expect(getAddProjectInitialQuery("")).toBe("~/"); | ||
| 23 | expect(getAddProjectInitialQuery("/work")).toBe("/work/"); | ||
| 24 | expect(getAddProjectInitialQuery("C:\\work")).toBe("C:\\work\\"); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("rejects unsupported windows paths on non-windows environments", () => { | ||
| 28 | expect( | ||
| 29 | resolveAddProjectPath({ | ||
| 30 | rawPath: "C:\\repo", | ||
| 31 | platform: "MacIntel", | ||
| 32 | currentProjectCwd: null, | ||
| 33 | }), | ||
| 34 | ).toEqual({ | ||
| 35 | ok: false, | ||
| 36 | error: "Windows-style paths are only supported on Windows environments.", | ||
| 37 | }); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("resolves relative paths from the active project cwd", () => { | ||
| 41 | expect( | ||
| 42 | resolveAddProjectPath({ | ||
| 43 | rawPath: "../next", | ||
| 44 | platform: "Linux", | ||
| 45 | currentProjectCwd: "/work/current", | ||
| 46 | }), | ||
| 47 | ).toEqual({ ok: true, path: "/work/next" }); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("marks authenticated source control providers as ready", () => { | ||
| 51 | const discovery: SourceControlDiscoveryResult = { | ||
| 52 | versionControlSystems: [], | ||
| 53 | sourceControlProviders: [ | ||
| 54 | { | ||
| 55 | kind: "github", | ||
| 56 | label: "GitHub", | ||
| 57 | status: "available", | ||
| 58 | installHint: "Install gh", | ||
| 59 | version: Option.some("1.0.0"), | ||
| 60 | detail: Option.none(), | ||
| 61 | auth: { | ||
| 62 | status: "authenticated", | ||
| 63 | account: Option.some("octo"), | ||
| 64 | host: Option.some("github.com"), | ||
| 65 | detail: Option.none(), | ||
| 66 | }, | ||
| 67 | }, | ||
| 68 | { | ||
| 69 | kind: "gitlab", | ||
| 70 | label: "GitLab", | ||
| 71 | status: "available", | ||
| 72 | installHint: "Install glab", | ||
| 73 | version: Option.some("1.0.0"), | ||
| 74 | detail: Option.none(), | ||
| 75 | auth: { | ||
| 76 | status: "unauthenticated", | ||
| 77 | account: Option.none(), | ||
| 78 | host: Option.none(), | ||
| 79 | detail: Option.some("Run glab auth login"), | ||
| 80 | }, | ||
| 81 | }, | ||
| 82 | ], | ||
| 83 | }; | ||
| 84 | |||
| 85 | const readiness = buildAddProjectRemoteSourceReadiness(discovery); | ||
| 86 | expect(readiness.url.ready).toBe(true); | ||
| 87 | expect(readiness.github.ready).toBe(true); | ||
| 88 | expect(readiness.gitlab).toEqual({ ready: false, hint: "Run glab auth login" }); | ||
| 89 | expect(sortAddProjectProviderSources(readiness)[0]).toBe("github"); | ||
| 90 | }); | ||
| 91 | |||
| 92 | it("finds existing projects by normalized path in the target environment", () => { | ||
| 93 | const env = EnvironmentId.make("env"); | ||
| 94 | const other = EnvironmentId.make("other"); | ||
| 95 | const projects: EnvironmentScopedProjectShell[] = [ | ||
| 96 | { | ||
| 97 | environmentId: other, | ||
| 98 | id: ProjectId.make("same-path-other-env"), | ||
| 99 | title: "Other", | ||
| 100 | workspaceRoot: "/repo", | ||
| 101 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 102 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 103 | repositoryIdentity: null, | ||
| 104 | defaultModelSelection: null, | ||
| 105 | scripts: [], | ||
| 106 | }, | ||
| 107 | { | ||
| 108 | environmentId: env, | ||
| 109 | id: ProjectId.make("project"), | ||
| 110 | title: "Repo", | ||
| 111 | workspaceRoot: "/repo/", | ||
| 112 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 113 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 114 | repositoryIdentity: null, | ||
| 115 | defaultModelSelection: null, | ||
| 116 | scripts: [], | ||
| 117 | }, | ||
| 118 | ]; | ||
| 119 | |||
| 120 | expect(findExistingAddProject({ projects, environmentId: env, path: "/repo" })?.id).toBe( | ||
| 121 | "project", | ||
| 122 | ); | ||
| 123 | }); | ||
| 124 | |||
| 125 | it("builds the existing project.create command shape", () => { | ||
| 126 | expect( | ||
| 127 | buildProjectCreateCommand({ | ||
| 128 | commandId: CommandId.make("command"), | ||
| 129 | projectId: ProjectId.make("project"), | ||
| 130 | workspaceRoot: "/work/repo", | ||
| 131 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 132 | }), | ||
| 133 | ).toMatchObject({ | ||
| 134 | type: "project.create", | ||
| 135 | commandId: "command", | ||
| 136 | projectId: "project", | ||
| 137 | title: "repo", | ||
| 138 | workspaceRoot: "/work/repo", | ||
| 139 | createWorkspaceRootIfMissing: true, | ||
| 140 | defaultModelSelection: { | ||
| 141 | instanceId: "codex", | ||
| 142 | model: "gpt-5.4", | ||
| 143 | }, | ||
| 144 | }); | ||
| 145 | }); | ||
| 146 | }); | ||
packages/client-runtime/src/addProject.ts created+224| ... | @@ -0,0 +1,224 @@ | ||
| 1 | import type { | ||
| 2 | CommandId, | ||
| 3 | EnvironmentId, | ||
| 4 | OrchestrationCommand, | ||
| 5 | ProjectId, | ||
| 6 | SourceControlDiscoveryResult, | ||
| 7 | SourceControlProviderKind, | ||
| 8 | SourceControlRepositoryInfo, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { DEFAULT_MODEL, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 11 | import * as Arr from "effect/Array"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Order from "effect/Order"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | ensureBrowseDirectoryPath, | ||
| 17 | findProjectByPath, | ||
| 18 | inferProjectTitleFromPath, | ||
| 19 | isExplicitRelativeProjectPath, | ||
| 20 | isUnsupportedWindowsProjectPath, | ||
| 21 | resolveProjectPathForDispatch, | ||
| 22 | } from "./projectPaths.ts"; | ||
| 23 | import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; | ||
| 24 | |||
| 25 | export type AddProjectRemoteProviderKind = Extract< | ||
| 26 | SourceControlProviderKind, | ||
| 27 | "github" | "gitlab" | "bitbucket" | "azure-devops" | ||
| 28 | >; | ||
| 29 | export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; | ||
| 30 | |||
| 31 | export type AddProjectRemoteSourceReadiness = Record< | ||
| 32 | AddProjectRemoteSource, | ||
| 33 | { readonly ready: boolean; readonly hint: string | null } | ||
| 34 | >; | ||
| 35 | |||
| 36 | export type AddProjectCloneFlow = | ||
| 37 | | { | ||
| 38 | readonly step: "repository"; | ||
| 39 | readonly environmentId: EnvironmentId; | ||
| 40 | readonly source: AddProjectRemoteSource; | ||
| 41 | } | ||
| 42 | | { | ||
| 43 | readonly step: "confirm"; | ||
| 44 | readonly environmentId: EnvironmentId; | ||
| 45 | readonly source: AddProjectRemoteSource; | ||
| 46 | readonly repositoryInput: string; | ||
| 47 | readonly repository: SourceControlRepositoryInfo | null; | ||
| 48 | readonly remoteUrl: string; | ||
| 49 | }; | ||
| 50 | |||
| 51 | export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [ | ||
| 52 | "url", | ||
| 53 | "github", | ||
| 54 | "gitlab", | ||
| 55 | "bitbucket", | ||
| 56 | "azure-devops", | ||
| 57 | ]; | ||
| 58 | |||
| 59 | export const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [ | ||
| 60 | "github", | ||
| 61 | "gitlab", | ||
| 62 | "bitbucket", | ||
| 63 | "azure-devops", | ||
| 64 | ]; | ||
| 65 | |||
| 66 | export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): string { | ||
| 67 | switch (source) { | ||
| 68 | case "github": | ||
| 69 | return "GitHub"; | ||
| 70 | case "gitlab": | ||
| 71 | return "GitLab"; | ||
| 72 | case "bitbucket": | ||
| 73 | return "Bitbucket"; | ||
| 74 | case "azure-devops": | ||
| 75 | return "Azure DevOps"; | ||
| 76 | case "url": | ||
| 77 | return "Git URL"; | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string { | ||
| 82 | switch (source) { | ||
| 83 | case "github": | ||
| 84 | return "owner/repo"; | ||
| 85 | case "gitlab": | ||
| 86 | return "group/project"; | ||
| 87 | case "bitbucket": | ||
| 88 | return "workspace/repository"; | ||
| 89 | case "azure-devops": | ||
| 90 | return "project/repository"; | ||
| 91 | case "url": | ||
| 92 | return "URL"; | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | export function addProjectRemoteSourceProvider( | ||
| 97 | source: AddProjectRemoteSource, | ||
| 98 | ): AddProjectRemoteProviderKind | null { | ||
| 99 | return source === "url" ? null : source; | ||
| 100 | } | ||
| 101 | |||
| 102 | export function sortAddProjectProviderSources( | ||
| 103 | readinessBySource: AddProjectRemoteSourceReadiness, | ||
| 104 | ): ReadonlyArray<AddProjectRemoteProviderKind> { | ||
| 105 | return Arr.sort( | ||
| 106 | ADD_PROJECT_REMOTE_PROVIDER_SOURCES, | ||
| 107 | Order.mapInput( | ||
| 108 | Order.Struct({ | ||
| 109 | ready: Order.flip(Order.Boolean), | ||
| 110 | label: Order.String, | ||
| 111 | }), | ||
| 112 | (source: AddProjectRemoteProviderKind) => ({ | ||
| 113 | ready: readinessBySource[source].ready, | ||
| 114 | label: addProjectRemoteSourceLabel(source), | ||
| 115 | }), | ||
| 116 | ), | ||
| 117 | ); | ||
| 118 | } | ||
| 119 | |||
| 120 | export function buildAddProjectRemoteSourceReadiness( | ||
| 121 | discovery: SourceControlDiscoveryResult | null, | ||
| 122 | ): AddProjectRemoteSourceReadiness { | ||
| 123 | const unavailable = { | ||
| 124 | ready: false, | ||
| 125 | hint: "Provider status unavailable. Open Source Control settings and rescan.", | ||
| 126 | } as const; | ||
| 127 | const readiness: AddProjectRemoteSourceReadiness = { | ||
| 128 | url: { ready: true, hint: null }, | ||
| 129 | github: unavailable, | ||
| 130 | gitlab: unavailable, | ||
| 131 | bitbucket: unavailable, | ||
| 132 | "azure-devops": unavailable, | ||
| 133 | }; | ||
| 134 | |||
| 135 | if (!discovery) { | ||
| 136 | return readiness; | ||
| 137 | } | ||
| 138 | |||
| 139 | const providerByKind = new Map( | ||
| 140 | discovery.sourceControlProviders.map((provider) => [provider.kind, provider]), | ||
| 141 | ); | ||
| 142 | for (const source of ADD_PROJECT_REMOTE_SOURCES) { | ||
| 143 | const kind = addProjectRemoteSourceProvider(source); | ||
| 144 | if (!kind) continue; | ||
| 145 | const provider = providerByKind.get(kind); | ||
| 146 | if (!provider) { | ||
| 147 | readiness[source] = unavailable; | ||
| 148 | continue; | ||
| 149 | } | ||
| 150 | if (provider.status !== "available") { | ||
| 151 | readiness[source] = { ready: false, hint: provider.installHint }; | ||
| 152 | continue; | ||
| 153 | } | ||
| 154 | if (provider.auth.status === "unauthenticated") { | ||
| 155 | readiness[source] = { | ||
| 156 | ready: false, | ||
| 157 | hint: | ||
| 158 | Option.getOrNull(provider.auth.detail) ?? | ||
| 159 | `${provider.label} is not authenticated. Open Source Control settings for setup guidance.`, | ||
| 160 | }; | ||
| 161 | continue; | ||
| 162 | } | ||
| 163 | readiness[source] = { ready: true, hint: null }; | ||
| 164 | } | ||
| 165 | return readiness; | ||
| 166 | } | ||
| 167 | |||
| 168 | export function getAddProjectInitialQuery(baseDirectory: string | null | undefined): string { | ||
| 169 | const trimmed = baseDirectory?.trim() ?? ""; | ||
| 170 | return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed); | ||
| 171 | } | ||
| 172 | |||
| 173 | export function resolveAddProjectPath(input: { | ||
| 174 | readonly rawPath: string; | ||
| 175 | readonly currentProjectCwd?: string | null; | ||
| 176 | readonly platform: string; | ||
| 177 | }): { readonly ok: true; readonly path: string } | { readonly ok: false; readonly error: string } { | ||
| 178 | const rawPath = input.rawPath.trim(); | ||
| 179 | if (rawPath.length === 0) { | ||
| 180 | return { ok: false, error: "Enter a project path." }; | ||
| 181 | } | ||
| 182 | if (isUnsupportedWindowsProjectPath(rawPath, input.platform)) { | ||
| 183 | return { ok: false, error: "Windows-style paths are only supported on Windows environments." }; | ||
| 184 | } | ||
| 185 | if (isExplicitRelativeProjectPath(rawPath) && !input.currentProjectCwd) { | ||
| 186 | return { ok: false, error: "Relative paths require an active project in this environment." }; | ||
| 187 | } | ||
| 188 | const path = resolveProjectPathForDispatch(rawPath, input.currentProjectCwd); | ||
| 189 | return path.length === 0 ? { ok: false, error: "Enter a project path." } : { ok: true, path }; | ||
| 190 | } | ||
| 191 | |||
| 192 | export function findExistingAddProject(input: { | ||
| 193 | readonly projects: ReadonlyArray<EnvironmentScopedProjectShell>; | ||
| 194 | readonly environmentId: EnvironmentId; | ||
| 195 | readonly path: string; | ||
| 196 | }): EnvironmentScopedProjectShell | null { | ||
| 197 | return ( | ||
| 198 | findProjectByPath( | ||
| 199 | input.projects.filter((project) => project.environmentId === input.environmentId), | ||
| 200 | input.path, | ||
| 201 | ) ?? null | ||
| 202 | ); | ||
| 203 | } | ||
| 204 | |||
| 205 | export function buildProjectCreateCommand(input: { | ||
| 206 | readonly commandId: CommandId; | ||
| 207 | readonly projectId: ProjectId; | ||
| 208 | readonly workspaceRoot: string; | ||
| 209 | readonly createdAt: string; | ||
| 210 | }): Extract<OrchestrationCommand, { type: "project.create" }> { | ||
| 211 | return { | ||
| 212 | type: "project.create", | ||
| 213 | commandId: input.commandId, | ||
| 214 | projectId: input.projectId, | ||
| 215 | title: inferProjectTitleFromPath(input.workspaceRoot), | ||
| 216 | workspaceRoot: input.workspaceRoot, | ||
| 217 | createWorkspaceRootIfMissing: true, | ||
| 218 | defaultModelSelection: { | ||
| 219 | instanceId: ProviderInstanceId.make("codex"), | ||
| 220 | model: DEFAULT_MODEL, | ||
| 221 | }, | ||
| 222 | createdAt: input.createdAt, | ||
| 223 | }; | ||
| 224 | } | ||
packages/client-runtime/src/archivedThreadsState.test.ts created+96| ... | @@ -0,0 +1,96 @@ | ||
| 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | ||
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | type ArchivedThreadsClient, | ||
| 7 | createArchivedThreadsManager, | ||
| 8 | makeArchivedThreadsEnvironmentKey, | ||
| 9 | parseArchivedThreadsEnvironmentKey, | ||
| 10 | readArchivedThreadsSnapshotState, | ||
| 11 | } from "./archivedThreadsState.ts"; | ||
| 12 | |||
| 13 | let registry = AtomRegistry.make(); | ||
| 14 | |||
| 15 | function resetAtomRegistry() { | ||
| 16 | registry.dispose(); | ||
| 17 | registry = AtomRegistry.make(); | ||
| 18 | } | ||
| 19 | |||
| 20 | function createSnapshot(id: string): OrchestrationShellSnapshot { | ||
| 21 | return { | ||
| 22 | snapshotSequence: 1, | ||
| 23 | projects: [], | ||
| 24 | threads: [], | ||
| 25 | updatedAt: `2026-05-08T00:00:00.000Z`, | ||
| 26 | id, | ||
| 27 | } as OrchestrationShellSnapshot; | ||
| 28 | } | ||
| 29 | |||
| 30 | describe("createArchivedThreadsManager", () => { | ||
| 31 | afterEach(() => { | ||
| 32 | resetAtomRegistry(); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("loads archived snapshots for configured environment clients", async () => { | ||
| 36 | const envA = EnvironmentId.make("env-a"); | ||
| 37 | const envB = EnvironmentId.make("env-b"); | ||
| 38 | const clients = new Map<EnvironmentId, ArchivedThreadsClient>([ | ||
| 39 | [ | ||
| 40 | envA, | ||
| 41 | { | ||
| 42 | getArchivedShellSnapshot: vi.fn(async () => createSnapshot("a")), | ||
| 43 | }, | ||
| 44 | ], | ||
| 45 | [ | ||
| 46 | envB, | ||
| 47 | { | ||
| 48 | getArchivedShellSnapshot: vi.fn(async () => createSnapshot("b")), | ||
| 49 | }, | ||
| 50 | ], | ||
| 51 | ]); | ||
| 52 | const manager = createArchivedThreadsManager({ | ||
| 53 | getRegistry: () => registry, | ||
| 54 | getClient: (environmentId) => clients.get(environmentId) ?? null, | ||
| 55 | }); | ||
| 56 | |||
| 57 | const result = registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envB, envA]))); | ||
| 58 | |||
| 59 | await vi.waitFor(() => { | ||
| 60 | const state = readArchivedThreadsSnapshotState( | ||
| 61 | registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB]))), | ||
| 62 | ); | ||
| 63 | expect(state.snapshots.map((snapshot) => snapshot.environmentId)).toEqual([envA, envB]); | ||
| 64 | }); | ||
| 65 | expect(readArchivedThreadsSnapshotState(result).isLoading).toBe(true); | ||
| 66 | }); | ||
| 67 | |||
| 68 | it("refreshes known snapshot groups that include an environment", async () => { | ||
| 69 | const envA = EnvironmentId.make("env-a"); | ||
| 70 | const envB = EnvironmentId.make("env-b"); | ||
| 71 | const getArchivedShellSnapshot = vi.fn(async () => | ||
| 72 | createSnapshot(`a-${getArchivedShellSnapshot.mock.calls.length}`), | ||
| 73 | ); | ||
| 74 | const manager = createArchivedThreadsManager({ | ||
| 75 | getRegistry: () => registry, | ||
| 76 | getClient: (environmentId) => (environmentId === envA ? { getArchivedShellSnapshot } : null), | ||
| 77 | staleTimeMs: 60_000, | ||
| 78 | }); | ||
| 79 | |||
| 80 | const atom = manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB])); | ||
| 81 | registry.get(atom); | ||
| 82 | await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(1)); | ||
| 83 | |||
| 84 | manager.refreshForEnvironment(envA); | ||
| 85 | |||
| 86 | await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(2)); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("round-trips environment keys in sorted order", () => { | ||
| 90 | const envA = EnvironmentId.make("env-a"); | ||
| 91 | const envB = EnvironmentId.make("env-b"); | ||
| 92 | const key = makeArchivedThreadsEnvironmentKey([envB, envA]); | ||
| 93 | |||
| 94 | expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); | ||
| 95 | }); | ||
| 96 | }); | ||
packages/client-runtime/src/archivedThreadsState.ts created+138| ... | @@ -0,0 +1,138 @@ | ||
| 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import { pipe } from "effect/Function"; | ||
| 6 | import * as Order from "effect/Order"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Result from "effect/Result"; | ||
| 9 | import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 10 | |||
| 11 | export type ArchivedSnapshotEntry = { | ||
| 12 | readonly environmentId: EnvironmentId; | ||
| 13 | readonly snapshot: OrchestrationShellSnapshot; | ||
| 14 | }; | ||
| 15 | |||
| 16 | export interface ArchivedThreadsClient { | ||
| 17 | readonly getArchivedShellSnapshot: () => Promise<OrchestrationShellSnapshot>; | ||
| 18 | } | ||
| 19 | |||
| 20 | export interface ArchivedThreadsSnapshotState { | ||
| 21 | readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>; | ||
| 22 | readonly error: string | null; | ||
| 23 | readonly isLoading: boolean; | ||
| 24 | } | ||
| 25 | |||
| 26 | const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; | ||
| 27 | const DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS = 5_000; | ||
| 28 | const DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000; | ||
| 29 | const environmentIdOrder = Order.String as Order.Order<EnvironmentId>; | ||
| 30 | |||
| 31 | export function makeArchivedThreadsEnvironmentKey( | ||
| 32 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 33 | ): string { | ||
| 34 | return pipe(environmentIds, Arr.sort(environmentIdOrder), (sortedEnvironmentIds) => | ||
| 35 | sortedEnvironmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), | ||
| 36 | ); | ||
| 37 | } | ||
| 38 | |||
| 39 | export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<EnvironmentId> { | ||
| 40 | if (key.length === 0) { | ||
| 41 | return []; | ||
| 42 | } | ||
| 43 | return pipe( | ||
| 44 | key.split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), | ||
| 45 | Arr.map((environmentId) => EnvironmentId.make(environmentId)), | ||
| 46 | ); | ||
| 47 | } | ||
| 48 | |||
| 49 | export function readArchivedThreadsSnapshotState( | ||
| 50 | result: AsyncResult.AsyncResult<ReadonlyArray<ArchivedSnapshotEntry>, unknown>, | ||
| 51 | ): ArchivedThreadsSnapshotState { | ||
| 52 | const snapshots = Option.getOrElse(AsyncResult.value(result), () => []); | ||
| 53 | let error: string | null = null; | ||
| 54 | if (result._tag === "Failure") { | ||
| 55 | const cause = Cause.squash(result.cause); | ||
| 56 | error = cause instanceof Error ? cause.message : "Failed to load archived threads."; | ||
| 57 | } | ||
| 58 | |||
| 59 | return { | ||
| 60 | snapshots, | ||
| 61 | error, | ||
| 62 | isLoading: result.waiting, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | export function createArchivedThreadsManager(config: { | ||
| 67 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 68 | readonly getClient: (environmentId: EnvironmentId) => ArchivedThreadsClient | null; | ||
| 69 | readonly staleTimeMs?: number; | ||
| 70 | readonly idleTtlMs?: number; | ||
| 71 | }) { | ||
| 72 | const knownEnvironmentKeys = new Set<string>(); | ||
| 73 | const knownEnvironmentIdsByKey = new Map<string, ReadonlySet<EnvironmentId>>(); | ||
| 74 | const staleTime = config.staleTimeMs ?? DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS; | ||
| 75 | const idleTtl = config.idleTtlMs ?? DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS; | ||
| 76 | |||
| 77 | const snapshotsAtom = Atom.family((environmentKey: string) => { | ||
| 78 | knownEnvironmentKeys.add(environmentKey); | ||
| 79 | knownEnvironmentIdsByKey.set( | ||
| 80 | environmentKey, | ||
| 81 | new Set(parseArchivedThreadsEnvironmentKey(environmentKey)), | ||
| 82 | ); | ||
| 83 | return Atom.make( | ||
| 84 | Effect.promise(async (): Promise<ReadonlyArray<ArchivedSnapshotEntry>> => { | ||
| 85 | const snapshots = await Promise.all( | ||
| 86 | pipe( | ||
| 87 | parseArchivedThreadsEnvironmentKey(environmentKey), | ||
| 88 | Arr.map(async (environmentId) => { | ||
| 89 | const client = config.getClient(environmentId); | ||
| 90 | if (!client) { | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | return { | ||
| 94 | environmentId, | ||
| 95 | snapshot: await client.getArchivedShellSnapshot(), | ||
| 96 | }; | ||
| 97 | }), | ||
| 98 | ), | ||
| 99 | ); | ||
| 100 | return pipe( | ||
| 101 | snapshots, | ||
| 102 | Arr.filterMap((snapshot) => | ||
| 103 | snapshot !== null ? Result.succeed(snapshot) : Result.failVoid, | ||
| 104 | ), | ||
| 105 | ); | ||
| 106 | }), | ||
| 107 | ).pipe( | ||
| 108 | Atom.swr({ | ||
| 109 | staleTime, | ||
| 110 | revalidateOnMount: true, | ||
| 111 | }), | ||
| 112 | Atom.setIdleTTL(idleTtl), | ||
| 113 | Atom.withLabel(`archived-thread-snapshots:${environmentKey}`), | ||
| 114 | ); | ||
| 115 | }); | ||
| 116 | |||
| 117 | function getAtom(environmentKey: string) { | ||
| 118 | return snapshotsAtom(environmentKey); | ||
| 119 | } | ||
| 120 | |||
| 121 | function refresh(environmentIds: ReadonlyArray<EnvironmentId>): void { | ||
| 122 | config.getRegistry().refresh(getAtom(makeArchivedThreadsEnvironmentKey(environmentIds))); | ||
| 123 | } | ||
| 124 | |||
| 125 | function refreshForEnvironment(environmentId: EnvironmentId): void { | ||
| 126 | for (const environmentKey of knownEnvironmentKeys) { | ||
| 127 | if (knownEnvironmentIdsByKey.get(environmentKey)?.has(environmentId)) { | ||
| 128 | config.getRegistry().refresh(getAtom(environmentKey)); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | return { | ||
| 134 | getAtom, | ||
| 135 | refresh, | ||
| 136 | refreshForEnvironment, | ||
| 137 | }; | ||
| 138 | } | ||
packages/client-runtime/src/checkpointDiffState.test.ts created+135| ... | @@ -0,0 +1,135 @@ | ||
| 1 | import { EnvironmentId, ThreadId, type OrchestrationGetTurnDiffResult } from "@t3tools/contracts"; | ||
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | type CheckpointDiffClient, | ||
| 7 | createCheckpointDiffManager, | ||
| 8 | EMPTY_CHECKPOINT_DIFF_STATE, | ||
| 9 | getCheckpointDiffTargetKey, | ||
| 10 | } from "./checkpointDiffState.ts"; | ||
| 11 | |||
| 12 | let registry = AtomRegistry.make(); | ||
| 13 | |||
| 14 | function resetAtomRegistry() { | ||
| 15 | registry.dispose(); | ||
| 16 | registry = AtomRegistry.make(); | ||
| 17 | } | ||
| 18 | |||
| 19 | const TARGET = { | ||
| 20 | environmentId: EnvironmentId.make("env-local"), | ||
| 21 | threadId: ThreadId.make("thread-1"), | ||
| 22 | fromTurnCount: 1, | ||
| 23 | toTurnCount: 2, | ||
| 24 | ignoreWhitespace: false, | ||
| 25 | }; | ||
| 26 | |||
| 27 | const PATCH_RESULT: OrchestrationGetTurnDiffResult = { | ||
| 28 | threadId: TARGET.threadId, | ||
| 29 | diff: "patch", | ||
| 30 | fromTurnCount: 1, | ||
| 31 | toTurnCount: 2, | ||
| 32 | }; | ||
| 33 | |||
| 34 | function createClient() { | ||
| 35 | return { | ||
| 36 | getTurnDiff: vi.fn(async () => PATCH_RESULT), | ||
| 37 | getFullThreadDiff: vi.fn(async () => PATCH_RESULT), | ||
| 38 | } satisfies CheckpointDiffClient; | ||
| 39 | } | ||
| 40 | |||
| 41 | describe("createCheckpointDiffManager", () => { | ||
| 42 | afterEach(() => { | ||
| 43 | resetAtomRegistry(); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("loads a turn checkpoint diff into atom state", async () => { | ||
| 47 | const client = createClient(); | ||
| 48 | const manager = createCheckpointDiffManager({ | ||
| 49 | getRegistry: () => registry, | ||
| 50 | getClient: () => client, | ||
| 51 | }); | ||
| 52 | |||
| 53 | await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT); | ||
| 54 | |||
| 55 | expect(client.getTurnDiff).toHaveBeenCalledWith({ | ||
| 56 | threadId: TARGET.threadId, | ||
| 57 | fromTurnCount: 1, | ||
| 58 | toTurnCount: 2, | ||
| 59 | ignoreWhitespace: false, | ||
| 60 | }); | ||
| 61 | expect(client.getFullThreadDiff).not.toHaveBeenCalled(); | ||
| 62 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 63 | data: PATCH_RESULT, | ||
| 64 | error: null, | ||
| 65 | isPending: false, | ||
| 66 | }); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("loads a full thread diff when the range starts at zero", async () => { | ||
| 70 | const client = createClient(); | ||
| 71 | const manager = createCheckpointDiffManager({ | ||
| 72 | getRegistry: () => registry, | ||
| 73 | getClient: () => client, | ||
| 74 | }); | ||
| 75 | |||
| 76 | await manager.load({ ...TARGET, fromTurnCount: 0 }); | ||
| 77 | |||
| 78 | expect(client.getFullThreadDiff).toHaveBeenCalledWith({ | ||
| 79 | threadId: TARGET.threadId, | ||
| 80 | toTurnCount: 2, | ||
| 81 | ignoreWhitespace: false, | ||
| 82 | }); | ||
| 83 | expect(client.getTurnDiff).not.toHaveBeenCalled(); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("returns empty state for invalid targets", () => { | ||
| 87 | const manager = createCheckpointDiffManager({ | ||
| 88 | getRegistry: () => registry, | ||
| 89 | getClient: () => createClient(), | ||
| 90 | }); | ||
| 91 | |||
| 92 | expect(manager.getSnapshot({ ...TARGET, threadId: null })).toBe(EMPTY_CHECKPOINT_DIFF_STATE); | ||
| 93 | expect(getCheckpointDiffTargetKey({ ...TARGET, threadId: null })).toBeNull(); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("deduplicates in-flight requests and reuses successful cached data", async () => { | ||
| 97 | const client = createClient(); | ||
| 98 | const manager = createCheckpointDiffManager({ | ||
| 99 | getRegistry: () => registry, | ||
| 100 | getClient: () => client, | ||
| 101 | }); | ||
| 102 | |||
| 103 | const first = manager.load(TARGET); | ||
| 104 | const second = manager.load(TARGET); | ||
| 105 | |||
| 106 | expect(first).toBe(second); | ||
| 107 | await first; | ||
| 108 | await manager.load(TARGET); | ||
| 109 | |||
| 110 | expect(client.getTurnDiff).toHaveBeenCalledTimes(1); | ||
| 111 | }); | ||
| 112 | |||
| 113 | it("retries temporarily unavailable checkpoint diffs", async () => { | ||
| 114 | let attempts = 0; | ||
| 115 | const client = { | ||
| 116 | getFullThreadDiff: vi.fn(async () => PATCH_RESULT), | ||
| 117 | getTurnDiff: vi.fn(async () => { | ||
| 118 | attempts += 1; | ||
| 119 | if (attempts < 3) { | ||
| 120 | throw new Error("checkpoint is unavailable for turn"); | ||
| 121 | } | ||
| 122 | return PATCH_RESULT; | ||
| 123 | }), | ||
| 124 | } satisfies CheckpointDiffClient; | ||
| 125 | const manager = createCheckpointDiffManager({ | ||
| 126 | getRegistry: () => registry, | ||
| 127 | getClient: () => client, | ||
| 128 | retryDelay: async () => undefined, | ||
| 129 | }); | ||
| 130 | |||
| 131 | await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT); | ||
| 132 | |||
| 133 | expect(client.getTurnDiff).toHaveBeenCalledTimes(3); | ||
| 134 | }); | ||
| 135 | }); | ||
packages/client-runtime/src/checkpointDiffState.ts created+313| ... | @@ -0,0 +1,313 @@ | ||
| 1 | import { | ||
| 2 | type EnvironmentId, | ||
| 3 | OrchestrationGetFullThreadDiffInput, | ||
| 4 | type OrchestrationGetFullThreadDiffResult, | ||
| 5 | OrchestrationGetTurnDiffInput, | ||
| 6 | type OrchestrationGetTurnDiffResult, | ||
| 7 | type ThreadId, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import * as Duration from "effect/Duration"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 14 | |||
| 15 | export type CheckpointDiffResult = | ||
| 16 | | OrchestrationGetTurnDiffResult | ||
| 17 | | OrchestrationGetFullThreadDiffResult; | ||
| 18 | |||
| 19 | export interface CheckpointDiffState { | ||
| 20 | readonly data: CheckpointDiffResult | null; | ||
| 21 | readonly error: string | null; | ||
| 22 | readonly isPending: boolean; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface CheckpointDiffTarget { | ||
| 26 | readonly environmentId: EnvironmentId | null; | ||
| 27 | readonly threadId: ThreadId | null; | ||
| 28 | readonly fromTurnCount: number | null; | ||
| 29 | readonly toTurnCount: number | null; | ||
| 30 | readonly ignoreWhitespace: boolean; | ||
| 31 | readonly cacheScope?: string | null; | ||
| 32 | } | ||
| 33 | |||
| 34 | export interface CheckpointDiffClient { | ||
| 35 | readonly getTurnDiff: ( | ||
| 36 | input: OrchestrationGetTurnDiffInput, | ||
| 37 | ) => Promise<OrchestrationGetTurnDiffResult>; | ||
| 38 | readonly getFullThreadDiff: ( | ||
| 39 | input: OrchestrationGetFullThreadDiffInput, | ||
| 40 | ) => Promise<OrchestrationGetFullThreadDiffResult>; | ||
| 41 | } | ||
| 42 | |||
| 43 | export const EMPTY_CHECKPOINT_DIFF_STATE = Object.freeze<CheckpointDiffState>({ | ||
| 44 | data: null, | ||
| 45 | error: null, | ||
| 46 | isPending: false, | ||
| 47 | }); | ||
| 48 | |||
| 49 | const INITIAL_CHECKPOINT_DIFF_STATE = Object.freeze<CheckpointDiffState>({ | ||
| 50 | data: null, | ||
| 51 | error: null, | ||
| 52 | isPending: true, | ||
| 53 | }); | ||
| 54 | |||
| 55 | const knownCheckpointDiffKeys = new Set<string>(); | ||
| 56 | |||
| 57 | export const checkpointDiffStateAtom = Atom.family((key: string) => { | ||
| 58 | knownCheckpointDiffKeys.add(key); | ||
| 59 | return Atom.make(INITIAL_CHECKPOINT_DIFF_STATE).pipe( | ||
| 60 | Atom.keepAlive, | ||
| 61 | Atom.withLabel(`checkpoint-diff:${key}`), | ||
| 62 | ); | ||
| 63 | }); | ||
| 64 | |||
| 65 | export const EMPTY_CHECKPOINT_DIFF_ATOM = Atom.make(EMPTY_CHECKPOINT_DIFF_STATE).pipe( | ||
| 66 | Atom.keepAlive, | ||
| 67 | Atom.withLabel("checkpoint-diff:null"), | ||
| 68 | ); | ||
| 69 | |||
| 70 | const decodeFullThreadDiffInput = Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput); | ||
| 71 | const decodeTurnDiffInput = Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput); | ||
| 72 | |||
| 73 | type CheckpointDiffRequest = | ||
| 74 | | { | ||
| 75 | readonly kind: "fullThreadDiff"; | ||
| 76 | readonly input: OrchestrationGetFullThreadDiffInput; | ||
| 77 | } | ||
| 78 | | { | ||
| 79 | readonly kind: "turnDiff"; | ||
| 80 | readonly input: OrchestrationGetTurnDiffInput; | ||
| 81 | }; | ||
| 82 | |||
| 83 | export function getCheckpointDiffTargetKey(target: CheckpointDiffTarget): string | null { | ||
| 84 | const decoded = decodeCheckpointDiffRequest(target); | ||
| 85 | if (target.environmentId === null || decoded._tag === "None") { | ||
| 86 | return null; | ||
| 87 | } | ||
| 88 | |||
| 89 | return [ | ||
| 90 | target.environmentId, | ||
| 91 | target.threadId, | ||
| 92 | target.fromTurnCount, | ||
| 93 | target.toTurnCount, | ||
| 94 | target.ignoreWhitespace, | ||
| 95 | target.cacheScope ?? null, | ||
| 96 | ].join(":"); | ||
| 97 | } | ||
| 98 | |||
| 99 | function decodeCheckpointDiffRequest(target: CheckpointDiffTarget) { | ||
| 100 | if (target.fromTurnCount === 0) { | ||
| 101 | return decodeFullThreadDiffInput({ | ||
| 102 | threadId: target.threadId, | ||
| 103 | toTurnCount: target.toTurnCount, | ||
| 104 | ignoreWhitespace: target.ignoreWhitespace, | ||
| 105 | }).pipe(Option.map((input) => ({ kind: "fullThreadDiff" as const, input }))); | ||
| 106 | } | ||
| 107 | |||
| 108 | return decodeTurnDiffInput({ | ||
| 109 | threadId: target.threadId, | ||
| 110 | fromTurnCount: target.fromTurnCount, | ||
| 111 | toTurnCount: target.toTurnCount, | ||
| 112 | ignoreWhitespace: target.ignoreWhitespace, | ||
| 113 | }).pipe(Option.map((input) => ({ kind: "turnDiff" as const, input }))); | ||
| 114 | } | ||
| 115 | |||
| 116 | function asCheckpointErrorMessage(error: unknown): string { | ||
| 117 | if (error instanceof Error) { | ||
| 118 | return error.message; | ||
| 119 | } | ||
| 120 | if (typeof error === "string") { | ||
| 121 | return error; | ||
| 122 | } | ||
| 123 | return ""; | ||
| 124 | } | ||
| 125 | |||
| 126 | export function normalizeCheckpointDiffErrorMessage(error: unknown): string { | ||
| 127 | const message = asCheckpointErrorMessage(error).trim(); | ||
| 128 | if (message.length === 0) { | ||
| 129 | return "Failed to load checkpoint diff."; | ||
| 130 | } | ||
| 131 | |||
| 132 | const lower = message.toLowerCase(); | ||
| 133 | if (lower.includes("not a git repository")) { | ||
| 134 | return "Turn diffs are unavailable because this project is not a git repository."; | ||
| 135 | } | ||
| 136 | |||
| 137 | if ( | ||
| 138 | lower.includes("checkpoint unavailable for thread") || | ||
| 139 | lower.includes("checkpoint invariant violation") | ||
| 140 | ) { | ||
| 141 | const separatorIndex = message.indexOf(":"); | ||
| 142 | if (separatorIndex >= 0) { | ||
| 143 | const detail = message.slice(separatorIndex + 1).trim(); | ||
| 144 | if (detail.length > 0) { | ||
| 145 | return detail; | ||
| 146 | } | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 150 | return message; | ||
| 151 | } | ||
| 152 | |||
| 153 | function isCheckpointTemporarilyUnavailable(error: unknown): boolean { | ||
| 154 | const message = asCheckpointErrorMessage(error).toLowerCase(); | ||
| 155 | return ( | ||
| 156 | message.includes("exceeds current turn count") || | ||
| 157 | message.includes("checkpoint is unavailable for turn") || | ||
| 158 | message.includes("filesystem checkpoint is unavailable") | ||
| 159 | ); | ||
| 160 | } | ||
| 161 | |||
| 162 | function defaultRetryDelay(attempt: number, error: unknown): Promise<void> { | ||
| 163 | const delayMs = isCheckpointTemporarilyUnavailable(error) | ||
| 164 | ? Math.min(5_000, 250 * 2 ** (attempt - 1)) | ||
| 165 | : Math.min(1_000, 100 * 2 ** (attempt - 1)); | ||
| 166 | return Effect.runPromise(Effect.sleep(Duration.millis(delayMs))); | ||
| 167 | } | ||
| 168 | |||
| 169 | export function createCheckpointDiffManager(config: { | ||
| 170 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 171 | readonly getClient: (environmentId: EnvironmentId) => CheckpointDiffClient | null; | ||
| 172 | readonly retryDelay?: (attempt: number, error: unknown) => Promise<void>; | ||
| 173 | }) { | ||
| 174 | const inFlight = new Map<string, Promise<CheckpointDiffResult | null>>(); | ||
| 175 | const versions = new Map<string, number>(); | ||
| 176 | |||
| 177 | function getVersion(targetKey: string): number { | ||
| 178 | return versions.get(targetKey) ?? 0; | ||
| 179 | } | ||
| 180 | |||
| 181 | function bumpVersion(targetKey: string): void { | ||
| 182 | versions.set(targetKey, getVersion(targetKey) + 1); | ||
| 183 | } | ||
| 184 | |||
| 185 | function setState(targetKey: string, state: CheckpointDiffState): void { | ||
| 186 | config.getRegistry().set(checkpointDiffStateAtom(targetKey), state); | ||
| 187 | } | ||
| 188 | |||
| 189 | function markPending(targetKey: string): void { | ||
| 190 | const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); | ||
| 191 | setState( | ||
| 192 | targetKey, | ||
| 193 | current.data === null ? INITIAL_CHECKPOINT_DIFF_STATE : { ...current, isPending: true }, | ||
| 194 | ); | ||
| 195 | } | ||
| 196 | |||
| 197 | function setError(targetKey: string, error: unknown): void { | ||
| 198 | const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); | ||
| 199 | setState(targetKey, { | ||
| 200 | data: current.data, | ||
| 201 | error: normalizeCheckpointDiffErrorMessage(error), | ||
| 202 | isPending: false, | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | |||
| 206 | async function requestWithRetry( | ||
| 207 | client: CheckpointDiffClient, | ||
| 208 | request: CheckpointDiffRequest, | ||
| 209 | ): Promise<CheckpointDiffResult> { | ||
| 210 | let attempt = 0; | ||
| 211 | while (true) { | ||
| 212 | attempt += 1; | ||
| 213 | try { | ||
| 214 | if (request.kind === "fullThreadDiff") { | ||
| 215 | return await client.getFullThreadDiff(request.input); | ||
| 216 | } | ||
| 217 | return await client.getTurnDiff(request.input); | ||
| 218 | } catch (error) { | ||
| 219 | const maxAttempts = isCheckpointTemporarilyUnavailable(error) ? 13 : 4; | ||
| 220 | if (attempt >= maxAttempts) { | ||
| 221 | throw error; | ||
| 222 | } | ||
| 223 | await (config.retryDelay ?? defaultRetryDelay)(attempt, error); | ||
| 224 | } | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | function load( | ||
| 229 | target: CheckpointDiffTarget, | ||
| 230 | client?: CheckpointDiffClient, | ||
| 231 | options?: { readonly force?: boolean }, | ||
| 232 | ): Promise<CheckpointDiffResult | null> { | ||
| 233 | const targetKey = getCheckpointDiffTargetKey(target); | ||
| 234 | const decoded = decodeCheckpointDiffRequest(target); | ||
| 235 | if (targetKey === null || target.environmentId === null || decoded._tag === "None") { | ||
| 236 | return Promise.resolve(null); | ||
| 237 | } | ||
| 238 | |||
| 239 | if (!options?.force) { | ||
| 240 | const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); | ||
| 241 | if (current.data !== null && current.error === null) { | ||
| 242 | return Promise.resolve(current.data); | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | const existing = inFlight.get(targetKey); | ||
| 247 | if (existing) { | ||
| 248 | return existing; | ||
| 249 | } | ||
| 250 | |||
| 251 | const resolved = client ?? config.getClient(target.environmentId); | ||
| 252 | if (!resolved) { | ||
| 253 | setError(targetKey, new Error("Remote connection is not ready.")); | ||
| 254 | return Promise.resolve(config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data); | ||
| 255 | } | ||
| 256 | |||
| 257 | markPending(targetKey); | ||
| 258 | const version = getVersion(targetKey); | ||
| 259 | const promise = requestWithRetry(resolved, decoded.value).then( | ||
| 260 | (result) => { | ||
| 261 | if (getVersion(targetKey) === version) { | ||
| 262 | setState(targetKey, { data: result, error: null, isPending: false }); | ||
| 263 | } | ||
| 264 | return result; | ||
| 265 | }, | ||
| 266 | (error: unknown) => { | ||
| 267 | if (getVersion(targetKey) === version) { | ||
| 268 | setError(targetKey, error); | ||
| 269 | } | ||
| 270 | return config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data; | ||
| 271 | }, | ||
| 272 | ); | ||
| 273 | inFlight.set(targetKey, promise); | ||
| 274 | void promise.finally(() => { | ||
| 275 | if (inFlight.get(targetKey) === promise) { | ||
| 276 | inFlight.delete(targetKey); | ||
| 277 | } | ||
| 278 | }); | ||
| 279 | return promise; | ||
| 280 | } | ||
| 281 | |||
| 282 | function getSnapshot(target: CheckpointDiffTarget): CheckpointDiffState { | ||
| 283 | const targetKey = getCheckpointDiffTargetKey(target); | ||
| 284 | return targetKey === null | ||
| 285 | ? EMPTY_CHECKPOINT_DIFF_STATE | ||
| 286 | : config.getRegistry().get(checkpointDiffStateAtom(targetKey)); | ||
| 287 | } | ||
| 288 | |||
| 289 | function invalidate(target?: CheckpointDiffTarget): void { | ||
| 290 | if (target) { | ||
| 291 | const targetKey = getCheckpointDiffTargetKey(target); | ||
| 292 | if (targetKey === null) { | ||
| 293 | return; | ||
| 294 | } | ||
| 295 | bumpVersion(targetKey); | ||
| 296 | inFlight.delete(targetKey); | ||
| 297 | setState(targetKey, INITIAL_CHECKPOINT_DIFF_STATE); | ||
| 298 | return; | ||
| 299 | } | ||
| 300 | |||
| 301 | for (const key of knownCheckpointDiffKeys) { | ||
| 302 | bumpVersion(key); | ||
| 303 | setState(key, INITIAL_CHECKPOINT_DIFF_STATE); | ||
| 304 | } | ||
| 305 | inFlight.clear(); | ||
| 306 | } | ||
| 307 | |||
| 308 | return { | ||
| 309 | getSnapshot, | ||
| 310 | invalidate, | ||
| 311 | load, | ||
| 312 | }; | ||
| 313 | } | ||
packages/client-runtime/src/composerPathSearchState.test.ts created+185| ... | @@ -0,0 +1,185 @@ | ||
| 1 | import { assert, beforeEach, it, vi } 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("reuses fresh cached path search results", async () => { | ||
| 66 | const searchEntries = vi.fn(async () => ({ | ||
| 67 | entries: [{ path: "src/index.ts", kind: "file" as const }], | ||
| 68 | truncated: false, | ||
| 69 | })); | ||
| 70 | const manager = createComposerPathSearchManager({ | ||
| 71 | getRegistry: () => registry, | ||
| 72 | debounceMs: 0, | ||
| 73 | staleTimeMs: 15_000, | ||
| 74 | getClient: () => ({ searchEntries }), | ||
| 75 | }); | ||
| 76 | |||
| 77 | manager.search(TARGET); | ||
| 78 | await flushAsyncWork(); | ||
| 79 | manager.search(TARGET); | ||
| 80 | await flushAsyncWork(); | ||
| 81 | |||
| 82 | assert.strictEqual(searchEntries.mock.calls.length, 1); | ||
| 83 | assert.deepStrictEqual(manager.getSnapshot(TARGET), { | ||
| 84 | entries: [{ path: "src/index.ts", kind: "file" }], | ||
| 85 | isPending: false, | ||
| 86 | error: null, | ||
| 87 | }); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("invalidates watched path searches and refreshes without clearing entries", async () => { | ||
| 91 | type SearchResult = Awaited<ReturnType<ComposerPathSearchClient["searchEntries"]>>; | ||
| 92 | |||
| 93 | let resolveSecond: (value: SearchResult) => void = noop; | ||
| 94 | let callCount = 0; | ||
| 95 | const searchEntries = vi.fn((() => { | ||
| 96 | callCount += 1; | ||
| 97 | if (callCount === 1) { | ||
| 98 | return Promise.resolve({ | ||
| 99 | entries: [{ path: "src/old.ts", kind: "file" as const }], | ||
| 100 | truncated: false, | ||
| 101 | }); | ||
| 102 | } | ||
| 103 | return new Promise((resolve) => { | ||
| 104 | resolveSecond = resolve; | ||
| 105 | }); | ||
| 106 | }) satisfies ComposerPathSearchClient["searchEntries"]); | ||
| 107 | const manager = createComposerPathSearchManager({ | ||
| 108 | getRegistry: () => registry, | ||
| 109 | debounceMs: 0, | ||
| 110 | staleTimeMs: 15_000, | ||
| 111 | getClient: () => ({ searchEntries }), | ||
| 112 | }); | ||
| 113 | |||
| 114 | const unwatch = manager.watch(TARGET); | ||
| 115 | await flushAsyncWork(); | ||
| 116 | manager.invalidate(); | ||
| 117 | |||
| 118 | assert.deepStrictEqual(manager.getSnapshot(TARGET), { | ||
| 119 | entries: [{ path: "src/old.ts", kind: "file" }], | ||
| 120 | isPending: true, | ||
| 121 | error: null, | ||
| 122 | }); | ||
| 123 | |||
| 124 | resolveSecond({ | ||
| 125 | entries: [{ path: "src/new.ts", kind: "file" }], | ||
| 126 | truncated: false, | ||
| 127 | }); | ||
| 128 | await flushAsyncWork(); | ||
| 129 | |||
| 130 | assert.deepStrictEqual(manager.getSnapshot(TARGET), { | ||
| 131 | entries: [{ path: "src/new.ts", kind: "file" }], | ||
| 132 | isPending: false, | ||
| 133 | error: null, | ||
| 134 | }); | ||
| 135 | assert.strictEqual(searchEntries.mock.calls.length, 2); | ||
| 136 | unwatch(); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it("ignores stale path search results after a newer request starts", async () => { | ||
| 140 | let resolveFirst: (value: { | ||
| 141 | entries: ReadonlyArray<{ path: string; kind: "file" | "directory" }>; | ||
| 142 | truncated: boolean; | ||
| 143 | }) => void = noop; | ||
| 144 | const manager = createComposerPathSearchManager({ | ||
| 145 | getRegistry: () => registry, | ||
| 146 | debounceMs: 0, | ||
| 147 | getClient: () => ({ | ||
| 148 | searchEntries: (input: Parameters<ComposerPathSearchClient["searchEntries"]>[0]) => { | ||
| 149 | if (input.query === "first") { | ||
| 150 | return new Promise((resolve) => { | ||
| 151 | resolveFirst = resolve; | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | return Promise.resolve({ | ||
| 155 | entries: [{ path: "second.ts", kind: "file" }], | ||
| 156 | truncated: false, | ||
| 157 | }); | ||
| 158 | }, | ||
| 159 | }), | ||
| 160 | }); | ||
| 161 | |||
| 162 | manager.search({ ...TARGET, query: "first" }); | ||
| 163 | manager.search({ ...TARGET, query: "second" }); | ||
| 164 | await flushAsyncWork(); | ||
| 165 | resolveFirst({ entries: [{ path: "first.ts", kind: "file" }], truncated: false }); | ||
| 166 | await flushAsyncWork(); | ||
| 167 | |||
| 168 | assert.deepStrictEqual(manager.getSnapshot({ ...TARGET, query: "second" }), { | ||
| 169 | entries: [{ path: "second.ts", kind: "file" }], | ||
| 170 | isPending: false, | ||
| 171 | error: null, | ||
| 172 | }); | ||
| 173 | }); | ||
| 174 | |||
| 175 | it("returns the empty snapshot for inactive targets", () => { | ||
| 176 | const manager = createComposerPathSearchManager({ | ||
| 177 | getRegistry: () => registry, | ||
| 178 | getClient: () => null, | ||
| 179 | }); | ||
| 180 | |||
| 181 | assert.deepStrictEqual( | ||
| 182 | manager.getSnapshot({ environmentId: null, cwd: null, query: null }), | ||
| 183 | EMPTY_COMPOSER_PATH_SEARCH_STATE, | ||
| 184 | ); | ||
| 185 | }); | ||
packages/client-runtime/src/composerPathSearchState.ts created+346| ... | @@ -0,0 +1,346 @@ | ||
| 1 | import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; | ||
| 2 | import * as Clock from "effect/Clock"; | ||
| 3 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 7 | |||
| 8 | export interface ComposerPathSearchEntry { | ||
| 9 | readonly path: string; | ||
| 10 | readonly kind: "file" | "directory"; | ||
| 11 | readonly parentPath?: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | export interface ComposerPathSearchState { | ||
| 15 | readonly entries: ReadonlyArray<ComposerPathSearchEntry>; | ||
| 16 | readonly isPending: boolean; | ||
| 17 | readonly error: string | null; | ||
| 18 | } | ||
| 19 | |||
| 20 | export interface ComposerPathSearchTarget { | ||
| 21 | readonly environmentId: EnvironmentId | null; | ||
| 22 | readonly cwd: string | null; | ||
| 23 | readonly query: string | null; | ||
| 24 | } | ||
| 25 | |||
| 26 | export interface ComposerPathSearchClient { | ||
| 27 | readonly searchEntries: (input: { | ||
| 28 | readonly cwd: string; | ||
| 29 | readonly query: string; | ||
| 30 | readonly limit: number; | ||
| 31 | }) => Promise<ProjectSearchEntriesResult>; | ||
| 32 | } | ||
| 33 | |||
| 34 | interface WatchedEntry { | ||
| 35 | refCount: number; | ||
| 36 | target: ComposerPathSearchTarget & { | ||
| 37 | readonly environmentId: EnvironmentId; | ||
| 38 | readonly cwd: string; | ||
| 39 | }; | ||
| 40 | teardown: () => void; | ||
| 41 | } | ||
| 42 | |||
| 43 | export const EMPTY_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({ | ||
| 44 | entries: [], | ||
| 45 | isPending: false, | ||
| 46 | error: null, | ||
| 47 | }); | ||
| 48 | |||
| 49 | const PENDING_COMPOSER_PATH_SEARCH_STATE = Object.freeze<ComposerPathSearchState>({ | ||
| 50 | entries: [], | ||
| 51 | isPending: true, | ||
| 52 | error: null, | ||
| 53 | }); | ||
| 54 | |||
| 55 | const NOOP: () => void = () => undefined; | ||
| 56 | const DEFAULT_DEBOUNCE_MS = 200; | ||
| 57 | const DEFAULT_LIMIT = 20; | ||
| 58 | |||
| 59 | export const composerPathSearchStateAtom = Atom.family((key: string) => | ||
| 60 | Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe( | ||
| 61 | Atom.keepAlive, | ||
| 62 | Atom.withLabel(`composer-path-search:${key}`), | ||
| 63 | ), | ||
| 64 | ); | ||
| 65 | |||
| 66 | export const EMPTY_COMPOSER_PATH_SEARCH_ATOM = Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe( | ||
| 67 | Atom.keepAlive, | ||
| 68 | Atom.withLabel("composer-path-search:null"), | ||
| 69 | ); | ||
| 70 | |||
| 71 | export function normalizeComposerPathSearchQuery(query: string | null): string { | ||
| 72 | return query?.trim() ?? ""; | ||
| 73 | } | ||
| 74 | |||
| 75 | export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget): string | null { | ||
| 76 | const query = normalizeComposerPathSearchQuery(target.query); | ||
| 77 | if (target.environmentId === null || target.cwd === null || query.length === 0) { | ||
| 78 | return null; | ||
| 79 | } | ||
| 80 | |||
| 81 | return `${target.environmentId}:${target.cwd}:${query}`; | ||
| 82 | } | ||
| 83 | |||
| 84 | function toSearchEntries( | ||
| 85 | entries: ProjectSearchEntriesResult["entries"], | ||
| 86 | ): ReadonlyArray<ComposerPathSearchEntry> { | ||
| 87 | return entries.map((entry) => ({ | ||
| 88 | path: entry.path, | ||
| 89 | kind: entry.kind === "directory" ? "directory" : "file", | ||
| 90 | ...(entry.parentPath !== undefined ? { parentPath: entry.parentPath } : {}), | ||
| 91 | })); | ||
| 92 | } | ||
| 93 | |||
| 94 | export function createComposerPathSearchManager(config: { | ||
| 95 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 96 | readonly getClient: (environmentId: EnvironmentId) => ComposerPathSearchClient | null; | ||
| 97 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 98 | readonly debounceMs?: number; | ||
| 99 | readonly limit?: number; | ||
| 100 | readonly staleTimeMs?: number; | ||
| 101 | }) { | ||
| 102 | const watched = new Map<string, WatchedEntry>(); | ||
| 103 | const versions = new Map<string, number>(); | ||
| 104 | const timers = new Map<string, Fiber.Fiber<void, never>>(); | ||
| 105 | const lastLoadedAt = new Map<string, number>(); | ||
| 106 | const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS; | ||
| 107 | const limit = config.limit ?? DEFAULT_LIMIT; | ||
| 108 | |||
| 109 | function bumpVersion(targetKey: string): number { | ||
| 110 | const next = (versions.get(targetKey) ?? 0) + 1; | ||
| 111 | versions.set(targetKey, next); | ||
| 112 | return next; | ||
| 113 | } | ||
| 114 | |||
| 115 | function setState(targetKey: string, state: ComposerPathSearchState): void { | ||
| 116 | config.getRegistry().set(composerPathSearchStateAtom(targetKey), state); | ||
| 117 | } | ||
| 118 | |||
| 119 | function clearTimer(targetKey: string): void { | ||
| 120 | const fiber = timers.get(targetKey); | ||
| 121 | if (fiber) { | ||
| 122 | Effect.runFork(Fiber.interrupt(fiber)); | ||
| 123 | timers.delete(targetKey); | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | function getSnapshot(target: ComposerPathSearchTarget): ComposerPathSearchState { | ||
| 128 | const targetKey = getComposerPathSearchTargetKey(target); | ||
| 129 | return targetKey === null | ||
| 130 | ? EMPTY_COMPOSER_PATH_SEARCH_STATE | ||
| 131 | : config.getRegistry().get(composerPathSearchStateAtom(targetKey)); | ||
| 132 | } | ||
| 133 | |||
| 134 | function runSearch( | ||
| 135 | targetKey: string, | ||
| 136 | target: ComposerPathSearchTarget & { | ||
| 137 | readonly environmentId: EnvironmentId; | ||
| 138 | readonly cwd: string; | ||
| 139 | }, | ||
| 140 | client: ComposerPathSearchClient, | ||
| 141 | version: number, | ||
| 142 | ): void { | ||
| 143 | void client | ||
| 144 | .searchEntries({ | ||
| 145 | cwd: target.cwd, | ||
| 146 | query: normalizeComposerPathSearchQuery(target.query), | ||
| 147 | limit, | ||
| 148 | }) | ||
| 149 | .then((result) => { | ||
| 150 | if (versions.get(targetKey) !== version) { | ||
| 151 | return; | ||
| 152 | } | ||
| 153 | setState(targetKey, { | ||
| 154 | entries: toSearchEntries(result.entries), | ||
| 155 | isPending: false, | ||
| 156 | error: null, | ||
| 157 | }); | ||
| 158 | lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis)); | ||
| 159 | }) | ||
| 160 | .catch((error: unknown) => { | ||
| 161 | if (versions.get(targetKey) !== version) { | ||
| 162 | return; | ||
| 163 | } | ||
| 164 | const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey)); | ||
| 165 | setState(targetKey, { | ||
| 166 | entries: current.entries, | ||
| 167 | isPending: false, | ||
| 168 | error: error instanceof Error ? error.message : "Failed to search project files.", | ||
| 169 | }); | ||
| 170 | }); | ||
| 171 | } | ||
| 172 | |||
| 173 | function search( | ||
| 174 | target: ComposerPathSearchTarget, | ||
| 175 | client?: ComposerPathSearchClient, | ||
| 176 | options?: { readonly force?: boolean }, | ||
| 177 | ): void { | ||
| 178 | const targetKey = getComposerPathSearchTargetKey(target); | ||
| 179 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 180 | return; | ||
| 181 | } | ||
| 182 | |||
| 183 | const resolved = client ?? config.getClient(target.environmentId); | ||
| 184 | if (!resolved) { | ||
| 185 | setState(targetKey, { | ||
| 186 | entries: [], | ||
| 187 | isPending: false, | ||
| 188 | error: "Remote connection is not ready.", | ||
| 189 | }); | ||
| 190 | return; | ||
| 191 | } | ||
| 192 | |||
| 193 | const lastLoaded = lastLoadedAt.get(targetKey); | ||
| 194 | if ( | ||
| 195 | !options?.force && | ||
| 196 | lastLoaded !== undefined && | ||
| 197 | config.staleTimeMs !== undefined && | ||
| 198 | Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs | ||
| 199 | ) { | ||
| 200 | return; | ||
| 201 | } | ||
| 202 | |||
| 203 | const version = bumpVersion(targetKey); | ||
| 204 | clearTimer(targetKey); | ||
| 205 | const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey)); | ||
| 206 | setState( | ||
| 207 | targetKey, | ||
| 208 | current.entries.length === 0 | ||
| 209 | ? PENDING_COMPOSER_PATH_SEARCH_STATE | ||
| 210 | : { ...current, isPending: true, error: null }, | ||
| 211 | ); | ||
| 212 | |||
| 213 | const readyTarget = { | ||
| 214 | ...target, | ||
| 215 | environmentId: target.environmentId, | ||
| 216 | cwd: target.cwd, | ||
| 217 | }; | ||
| 218 | |||
| 219 | if (debounceMs <= 0) { | ||
| 220 | runSearch(targetKey, readyTarget, resolved, version); | ||
| 221 | return; | ||
| 222 | } | ||
| 223 | |||
| 224 | const fiber = Effect.runFork( | ||
| 225 | Effect.sleep(Duration.millis(debounceMs)).pipe( | ||
| 226 | Effect.andThen( | ||
| 227 | Effect.sync(() => { | ||
| 228 | timers.delete(targetKey); | ||
| 229 | runSearch(targetKey, readyTarget, resolved, version); | ||
| 230 | }), | ||
| 231 | ), | ||
| 232 | ), | ||
| 233 | ); | ||
| 234 | timers.set(targetKey, fiber); | ||
| 235 | } | ||
| 236 | |||
| 237 | function watch(target: ComposerPathSearchTarget): () => void { | ||
| 238 | const targetKey = getComposerPathSearchTargetKey(target); | ||
| 239 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 240 | return NOOP; | ||
| 241 | } | ||
| 242 | |||
| 243 | const readyTarget = { | ||
| 244 | ...target, | ||
| 245 | environmentId: target.environmentId, | ||
| 246 | cwd: target.cwd, | ||
| 247 | }; | ||
| 248 | |||
| 249 | const existing = watched.get(targetKey); | ||
| 250 | if (existing) { | ||
| 251 | existing.refCount += 1; | ||
| 252 | return () => unwatch(targetKey); | ||
| 253 | } | ||
| 254 | |||
| 255 | let currentClient: ComposerPathSearchClient | null = null; | ||
| 256 | const sync = () => { | ||
| 257 | const client = config.getClient(target.environmentId!); | ||
| 258 | if (!client) { | ||
| 259 | currentClient = null; | ||
| 260 | setState(targetKey, { | ||
| 261 | entries: [], | ||
| 262 | isPending: false, | ||
| 263 | error: "Remote connection is not ready.", | ||
| 264 | }); | ||
| 265 | return; | ||
| 266 | } | ||
| 267 | |||
| 268 | if (currentClient === client) { | ||
| 269 | return; | ||
| 270 | } | ||
| 271 | |||
| 272 | currentClient = client; | ||
| 273 | search(readyTarget, client); | ||
| 274 | }; | ||
| 275 | |||
| 276 | const unsubscribe = config.subscribeClientChanges?.(sync) ?? NOOP; | ||
| 277 | sync(); | ||
| 278 | |||
| 279 | watched.set(targetKey, { | ||
| 280 | refCount: 1, | ||
| 281 | target: readyTarget, | ||
| 282 | teardown: () => { | ||
| 283 | unsubscribe(); | ||
| 284 | clearTimer(targetKey); | ||
| 285 | bumpVersion(targetKey); | ||
| 286 | }, | ||
| 287 | }); | ||
| 288 | |||
| 289 | return () => unwatch(targetKey); | ||
| 290 | } | ||
| 291 | |||
| 292 | function unwatch(targetKey: string): void { | ||
| 293 | const entry = watched.get(targetKey); | ||
| 294 | if (!entry) { | ||
| 295 | return; | ||
| 296 | } | ||
| 297 | |||
| 298 | entry.refCount -= 1; | ||
| 299 | if (entry.refCount > 0) { | ||
| 300 | return; | ||
| 301 | } | ||
| 302 | |||
| 303 | entry.teardown(); | ||
| 304 | watched.delete(targetKey); | ||
| 305 | } | ||
| 306 | |||
| 307 | function reset(): void { | ||
| 308 | for (const entry of watched.values()) { | ||
| 309 | entry.teardown(); | ||
| 310 | } | ||
| 311 | watched.clear(); | ||
| 312 | versions.clear(); | ||
| 313 | for (const targetKey of timers.keys()) { | ||
| 314 | clearTimer(targetKey); | ||
| 315 | } | ||
| 316 | lastLoadedAt.clear(); | ||
| 317 | } | ||
| 318 | |||
| 319 | function invalidate(target?: ComposerPathSearchTarget): void { | ||
| 320 | if (target) { | ||
| 321 | const targetKey = getComposerPathSearchTargetKey(target); | ||
| 322 | if (targetKey === null) { | ||
| 323 | return; | ||
| 324 | } | ||
| 325 | lastLoadedAt.delete(targetKey); | ||
| 326 | const watchedEntry = watched.get(targetKey); | ||
| 327 | if (watchedEntry) { | ||
| 328 | search(watchedEntry.target, undefined, { force: true }); | ||
| 329 | } | ||
| 330 | return; | ||
| 331 | } | ||
| 332 | |||
| 333 | lastLoadedAt.clear(); | ||
| 334 | for (const watchedEntry of watched.values()) { | ||
| 335 | search(watchedEntry.target, undefined, { force: true }); | ||
| 336 | } | ||
| 337 | } | ||
| 338 | |||
| 339 | return { | ||
| 340 | invalidate, | ||
| 341 | getSnapshot, | ||
| 342 | search, | ||
| 343 | watch, | ||
| 344 | reset, | ||
| 345 | }; | ||
| 346 | } | ||
packages/client-runtime/src/environmentConnection.ts created+244| ... | @@ -0,0 +1,244 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationShellSnapshot, | ||
| 4 | OrchestrationShellStreamEvent, | ||
| 5 | ServerConfig, | ||
| 6 | ServerLifecycleWelcomePayload, | ||
| 7 | TerminalEvent, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | |||
| 10 | import type { KnownEnvironment } from "./knownEnvironment.ts"; | ||
| 11 | import type { WsRpcClient } from "./wsRpcClient.ts"; | ||
| 12 | |||
| 13 | export interface EnvironmentConnection { | ||
| 14 | readonly kind: "primary" | "saved"; | ||
| 15 | readonly environmentId: EnvironmentId; | ||
| 16 | readonly knownEnvironment: KnownEnvironment; | ||
| 17 | readonly client: WsRpcClient; | ||
| 18 | readonly ensureBootstrapped: () => Promise<void>; | ||
| 19 | readonly reconnect: () => Promise<void>; | ||
| 20 | readonly dispose: () => Promise<void>; | ||
| 21 | } | ||
| 22 | |||
| 23 | interface OrchestrationHandlers { | ||
| 24 | readonly applyShellEvent: ( | ||
| 25 | event: OrchestrationShellStreamEvent, | ||
| 26 | environmentId: EnvironmentId, | ||
| 27 | ) => void; | ||
| 28 | readonly syncShellSnapshot: ( | ||
| 29 | snapshot: OrchestrationShellSnapshot, | ||
| 30 | environmentId: EnvironmentId, | ||
| 31 | ) => void; | ||
| 32 | readonly applyTerminalEvent?: (event: TerminalEvent, environmentId: EnvironmentId) => void; | ||
| 33 | } | ||
| 34 | |||
| 35 | export interface EnvironmentConnectionInput extends OrchestrationHandlers { | ||
| 36 | readonly kind: "primary" | "saved"; | ||
| 37 | readonly knownEnvironment: KnownEnvironment; | ||
| 38 | readonly client: WsRpcClient; | ||
| 39 | readonly refreshMetadata?: () => Promise<void>; | ||
| 40 | readonly onConfigSnapshot?: (config: ServerConfig) => void; | ||
| 41 | readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; | ||
| 42 | readonly onShellResubscribe?: (environmentId: EnvironmentId) => void; | ||
| 43 | } | ||
| 44 | |||
| 45 | export interface EnvironmentConnectionAttempt { | ||
| 46 | readonly environmentId: EnvironmentId; | ||
| 47 | readonly isCurrent: () => boolean; | ||
| 48 | } | ||
| 49 | |||
| 50 | export class EnvironmentConnectionAttemptCancelledError extends Error { | ||
| 51 | constructor(environmentId: EnvironmentId) { | ||
| 52 | super(`Environment connection attempt ${environmentId} was cancelled.`); | ||
| 53 | this.name = "EnvironmentConnectionAttemptCancelledError"; | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | export function createEnvironmentConnectionAttemptRegistry() { | ||
| 58 | const attempts = new Map<EnvironmentId, symbol>(); | ||
| 59 | |||
| 60 | return { | ||
| 61 | begin: (environmentId: EnvironmentId): EnvironmentConnectionAttempt => { | ||
| 62 | const id = Symbol(environmentId); | ||
| 63 | attempts.set(environmentId, id); | ||
| 64 | return { | ||
| 65 | environmentId, | ||
| 66 | isCurrent: () => attempts.get(environmentId) === id, | ||
| 67 | }; | ||
| 68 | }, | ||
| 69 | cancel: (environmentId: EnvironmentId): void => { | ||
| 70 | attempts.delete(environmentId); | ||
| 71 | }, | ||
| 72 | clear: (): void => { | ||
| 73 | attempts.clear(); | ||
| 74 | }, | ||
| 75 | }; | ||
| 76 | } | ||
| 77 | |||
| 78 | export class EnvironmentConnectionDisposedError extends Error { | ||
| 79 | constructor(environmentId: EnvironmentId) { | ||
| 80 | super(`Environment connection ${environmentId} was disposed before it finished bootstrapping.`); | ||
| 81 | this.name = "EnvironmentConnectionDisposedError"; | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | function createBootstrapGate() { | ||
| 86 | let resolve: (() => void) | null = null; | ||
| 87 | let reject: ((error: unknown) => void) | null = null; | ||
| 88 | const makePromise = () => { | ||
| 89 | const nextPromise = new Promise<void>((nextResolve, nextReject) => { | ||
| 90 | resolve = nextResolve; | ||
| 91 | reject = nextReject; | ||
| 92 | }); | ||
| 93 | void nextPromise.catch(() => undefined); | ||
| 94 | return nextPromise; | ||
| 95 | }; | ||
| 96 | let promise = makePromise(); | ||
| 97 | |||
| 98 | return { | ||
| 99 | wait: () => promise, | ||
| 100 | resolve: () => { | ||
| 101 | resolve?.(); | ||
| 102 | resolve = null; | ||
| 103 | reject = null; | ||
| 104 | }, | ||
| 105 | reject: (error: unknown) => { | ||
| 106 | reject?.(error); | ||
| 107 | resolve = null; | ||
| 108 | reject = null; | ||
| 109 | }, | ||
| 110 | reset: () => { | ||
| 111 | promise = makePromise(); | ||
| 112 | }, | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | |||
| 116 | export function createEnvironmentConnection( | ||
| 117 | input: EnvironmentConnectionInput, | ||
| 118 | ): EnvironmentConnection { | ||
| 119 | const environmentId = input.knownEnvironment.environmentId; | ||
| 120 | |||
| 121 | if (!environmentId) { | ||
| 122 | throw new Error( | ||
| 123 | `Known environment ${input.knownEnvironment.label} is missing its environmentId.`, | ||
| 124 | ); | ||
| 125 | } | ||
| 126 | |||
| 127 | let disposed = false; | ||
| 128 | const bootstrapGate = createBootstrapGate(); | ||
| 129 | const shouldObserveLifecycle = input.kind === "saved" || input.onWelcome !== undefined; | ||
| 130 | const shouldObserveConfig = input.kind === "saved" || input.onConfigSnapshot !== undefined; | ||
| 131 | |||
| 132 | const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { | ||
| 133 | if (environmentId !== nextEnvironmentId) { | ||
| 134 | throw new Error( | ||
| 135 | `Environment connection ${environmentId} changed identity to ${nextEnvironmentId} via ${source}.`, | ||
| 136 | ); | ||
| 137 | } | ||
| 138 | }; | ||
| 139 | |||
| 140 | const unsubLifecycle = shouldObserveLifecycle | ||
| 141 | ? input.client.server.subscribeLifecycle((event) => { | ||
| 142 | if (disposed || event.type !== "welcome") { | ||
| 143 | return; | ||
| 144 | } | ||
| 145 | |||
| 146 | observeEnvironmentIdentity( | ||
| 147 | event.payload.environment.environmentId, | ||
| 148 | "server lifecycle welcome", | ||
| 149 | ); | ||
| 150 | input.onWelcome?.(event.payload); | ||
| 151 | }) | ||
| 152 | : () => undefined; | ||
| 153 | |||
| 154 | const unsubConfig = shouldObserveConfig | ||
| 155 | ? input.client.server.subscribeConfig((event) => { | ||
| 156 | if (disposed || event.type !== "snapshot") { | ||
| 157 | return; | ||
| 158 | } | ||
| 159 | |||
| 160 | observeEnvironmentIdentity( | ||
| 161 | event.config.environment.environmentId, | ||
| 162 | "server config snapshot", | ||
| 163 | ); | ||
| 164 | input.onConfigSnapshot?.(event.config); | ||
| 165 | }) | ||
| 166 | : () => undefined; | ||
| 167 | |||
| 168 | const unsubShell = input.client.orchestration.subscribeShell( | ||
| 169 | (item) => { | ||
| 170 | if (disposed) { | ||
| 171 | return; | ||
| 172 | } | ||
| 173 | |||
| 174 | if (item.kind === "snapshot") { | ||
| 175 | input.syncShellSnapshot(item.snapshot, environmentId); | ||
| 176 | bootstrapGate.resolve(); | ||
| 177 | return; | ||
| 178 | } | ||
| 179 | |||
| 180 | input.applyShellEvent(item, environmentId); | ||
| 181 | }, | ||
| 182 | { | ||
| 183 | onResubscribe: () => { | ||
| 184 | if (disposed) { | ||
| 185 | return; | ||
| 186 | } | ||
| 187 | |||
| 188 | bootstrapGate.reset(); | ||
| 189 | input.onShellResubscribe?.(environmentId); | ||
| 190 | }, | ||
| 191 | }, | ||
| 192 | ); | ||
| 193 | |||
| 194 | const unsubTerminalEvent = input.applyTerminalEvent | ||
| 195 | ? input.client.terminal.onEvent((event) => { | ||
| 196 | if (!disposed) { | ||
| 197 | input.applyTerminalEvent?.(event, environmentId); | ||
| 198 | } | ||
| 199 | }) | ||
| 200 | : () => undefined; | ||
| 201 | |||
| 202 | const cleanup = () => { | ||
| 203 | if (disposed) { | ||
| 204 | return; | ||
| 205 | } | ||
| 206 | |||
| 207 | disposed = true; | ||
| 208 | bootstrapGate.reject(new EnvironmentConnectionDisposedError(environmentId)); | ||
| 209 | unsubShell(); | ||
| 210 | unsubTerminalEvent(); | ||
| 211 | unsubLifecycle(); | ||
| 212 | unsubConfig(); | ||
| 213 | }; | ||
| 214 | |||
| 215 | return { | ||
| 216 | kind: input.kind, | ||
| 217 | environmentId, | ||
| 218 | knownEnvironment: input.knownEnvironment, | ||
| 219 | client: input.client, | ||
| 220 | ensureBootstrapped: () => | ||
| 221 | disposed | ||
| 222 | ? Promise.reject(new EnvironmentConnectionDisposedError(environmentId)) | ||
| 223 | : bootstrapGate.wait(), | ||
| 224 | reconnect: async () => { | ||
| 225 | if (disposed) { | ||
| 226 | throw new EnvironmentConnectionDisposedError(environmentId); | ||
| 227 | } | ||
| 228 | |||
| 229 | bootstrapGate.reset(); | ||
| 230 | try { | ||
| 231 | await input.client.reconnect(); | ||
| 232 | await input.refreshMetadata?.(); | ||
| 233 | await bootstrapGate.wait(); | ||
| 234 | } catch (error) { | ||
| 235 | bootstrapGate.reject(error); | ||
| 236 | throw error; | ||
| 237 | } | ||
| 238 | }, | ||
| 239 | dispose: async () => { | ||
| 240 | cleanup(); | ||
| 241 | await input.client.dispose(); | ||
| 242 | }, | ||
| 243 | }; | ||
| 244 | } | ||
packages/client-runtime/src/environmentRuntimeState.test.ts created+75| ... | @@ -0,0 +1,75 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 4 | |||
| 5 | import { createEnvironmentRuntimeManager } from "./environmentRuntimeState.ts"; | ||
| 6 | |||
| 7 | let atomRegistry = AtomRegistry.make(); | ||
| 8 | |||
| 9 | function resetAtomRegistry() { | ||
| 10 | atomRegistry.dispose(); | ||
| 11 | atomRegistry = AtomRegistry.make(); | ||
| 12 | } | ||
| 13 | |||
| 14 | const TARGET = { environmentId: EnvironmentId.make("env-local") } as const; | ||
| 15 | |||
| 16 | describe("createEnvironmentRuntimeManager", () => { | ||
| 17 | afterEach(() => { | ||
| 18 | resetAtomRegistry(); | ||
| 19 | }); | ||
| 20 | |||
| 21 | it("stores state per environment", () => { | ||
| 22 | const manager = createEnvironmentRuntimeManager({ | ||
| 23 | getRegistry: () => atomRegistry, | ||
| 24 | }); | ||
| 25 | |||
| 26 | manager.setState(TARGET, { | ||
| 27 | connectionState: "ready", | ||
| 28 | connectionError: null, | ||
| 29 | serverConfig: null, | ||
| 30 | }); | ||
| 31 | |||
| 32 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 33 | connectionState: "ready", | ||
| 34 | connectionError: null, | ||
| 35 | serverConfig: null, | ||
| 36 | }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("patches the current state", () => { | ||
| 40 | const manager = createEnvironmentRuntimeManager({ | ||
| 41 | getRegistry: () => atomRegistry, | ||
| 42 | }); | ||
| 43 | |||
| 44 | manager.patch(TARGET, (current) => ({ | ||
| 45 | ...current, | ||
| 46 | connectionState: "disconnected", | ||
| 47 | connectionError: "Socket closed.", | ||
| 48 | })); | ||
| 49 | |||
| 50 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 51 | connectionState: "disconnected", | ||
| 52 | connectionError: "Socket closed.", | ||
| 53 | serverConfig: null, | ||
| 54 | }); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("invalidates a single environment", () => { | ||
| 58 | const manager = createEnvironmentRuntimeManager({ | ||
| 59 | getRegistry: () => atomRegistry, | ||
| 60 | }); | ||
| 61 | |||
| 62 | manager.setState(TARGET, { | ||
| 63 | connectionState: "ready", | ||
| 64 | connectionError: null, | ||
| 65 | serverConfig: null, | ||
| 66 | }); | ||
| 67 | manager.invalidate(TARGET); | ||
| 68 | |||
| 69 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 70 | connectionState: "idle", | ||
| 71 | connectionError: null, | ||
| 72 | serverConfig: null, | ||
| 73 | }); | ||
| 74 | }); | ||
| 75 | }); | ||
packages/client-runtime/src/environmentRuntimeState.ts created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import type { EnvironmentId, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; | ||
| 2 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | export type EnvironmentConnectionState = | ||
| 5 | | "idle" | ||
| 6 | | "connecting" | ||
| 7 | | "ready" | ||
| 8 | | "reconnecting" | ||
| 9 | | "disconnected"; | ||
| 10 | |||
| 11 | export interface EnvironmentRuntimeState { | ||
| 12 | readonly connectionState: EnvironmentConnectionState; | ||
| 13 | readonly connectionError: string | null; | ||
| 14 | readonly serverConfig: T3ServerConfig | null; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface EnvironmentRuntimeTarget { | ||
| 18 | readonly environmentId: EnvironmentId | null; | ||
| 19 | } | ||
| 20 | |||
| 21 | export const EMPTY_ENVIRONMENT_RUNTIME_STATE = Object.freeze<EnvironmentRuntimeState>({ | ||
| 22 | connectionState: "idle", | ||
| 23 | connectionError: null, | ||
| 24 | serverConfig: null, | ||
| 25 | }); | ||
| 26 | |||
| 27 | const knownEnvironmentRuntimeKeys = new Set<string>(); | ||
| 28 | |||
| 29 | export const environmentRuntimeStateAtom = Atom.family((key: string) => { | ||
| 30 | knownEnvironmentRuntimeKeys.add(key); | ||
| 31 | return Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe( | ||
| 32 | Atom.keepAlive, | ||
| 33 | Atom.withLabel(`environment-runtime:${key}`), | ||
| 34 | ); | ||
| 35 | }); | ||
| 36 | |||
| 37 | export const EMPTY_ENVIRONMENT_RUNTIME_ATOM = Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe( | ||
| 38 | Atom.keepAlive, | ||
| 39 | Atom.withLabel("environment-runtime:null"), | ||
| 40 | ); | ||
| 41 | |||
| 42 | export function getEnvironmentRuntimeTargetKey(target: EnvironmentRuntimeTarget): string | null { | ||
| 43 | return target.environmentId; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface EnvironmentRuntimeManagerConfig { | ||
| 47 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 48 | } | ||
| 49 | |||
| 50 | export function createEnvironmentRuntimeManager(config: EnvironmentRuntimeManagerConfig) { | ||
| 51 | function getSnapshot(target: EnvironmentRuntimeTarget): EnvironmentRuntimeState { | ||
| 52 | const targetKey = getEnvironmentRuntimeTargetKey(target); | ||
| 53 | if (targetKey === null) { | ||
| 54 | return EMPTY_ENVIRONMENT_RUNTIME_STATE; | ||
| 55 | } | ||
| 56 | |||
| 57 | return config.getRegistry().get(environmentRuntimeStateAtom(targetKey)); | ||
| 58 | } | ||
| 59 | |||
| 60 | function setState(target: EnvironmentRuntimeTarget, nextState: EnvironmentRuntimeState): void { | ||
| 61 | const targetKey = getEnvironmentRuntimeTargetKey(target); | ||
| 62 | if (targetKey === null) { | ||
| 63 | return; | ||
| 64 | } | ||
| 65 | |||
| 66 | config.getRegistry().set(environmentRuntimeStateAtom(targetKey), nextState); | ||
| 67 | } | ||
| 68 | |||
| 69 | function patch( | ||
| 70 | target: EnvironmentRuntimeTarget, | ||
| 71 | updater: (current: EnvironmentRuntimeState) => EnvironmentRuntimeState, | ||
| 72 | ): void { | ||
| 73 | const targetKey = getEnvironmentRuntimeTargetKey(target); | ||
| 74 | if (targetKey === null) { | ||
| 75 | return; | ||
| 76 | } | ||
| 77 | |||
| 78 | const current = config.getRegistry().get(environmentRuntimeStateAtom(targetKey)); | ||
| 79 | config.getRegistry().set(environmentRuntimeStateAtom(targetKey), updater(current)); | ||
| 80 | } | ||
| 81 | |||
| 82 | function invalidate(target?: EnvironmentRuntimeTarget): void { | ||
| 83 | if (target) { | ||
| 84 | setState(target, EMPTY_ENVIRONMENT_RUNTIME_STATE); | ||
| 85 | return; | ||
| 86 | } | ||
| 87 | |||
| 88 | for (const key of knownEnvironmentRuntimeKeys) { | ||
| 89 | config.getRegistry().set(environmentRuntimeStateAtom(key), EMPTY_ENVIRONMENT_RUNTIME_STATE); | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | function reset(): void { | ||
| 94 | invalidate(); | ||
| 95 | } | ||
| 96 | |||
| 97 | return { | ||
| 98 | getSnapshot, | ||
| 99 | setState, | ||
| 100 | patch, | ||
| 101 | invalidate, | ||
| 102 | reset, | ||
| 103 | }; | ||
| 104 | } | ||
packages/client-runtime/src/filesystemBrowseState.test.ts created+119| ... | @@ -0,0 +1,119 @@ | ||
| 1 | import { assert, beforeEach, it } from "vitest"; | ||
| 2 | import type { FilesystemBrowseResult } from "@t3tools/contracts"; | ||
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | EMPTY_FILESYSTEM_BROWSE_STATE, | ||
| 7 | createFilesystemBrowseManager, | ||
| 8 | } from "./filesystemBrowseState.ts"; | ||
| 9 | |||
| 10 | const ROOT_RESULT: FilesystemBrowseResult = { | ||
| 11 | parentPath: "/Users/julius", | ||
| 12 | entries: [ | ||
| 13 | { | ||
| 14 | name: "code", | ||
| 15 | fullPath: "/Users/julius/code", | ||
| 16 | }, | ||
| 17 | ], | ||
| 18 | }; | ||
| 19 | |||
| 20 | let registry = AtomRegistry.make(); | ||
| 21 | |||
| 22 | beforeEach(() => { | ||
| 23 | registry.dispose(); | ||
| 24 | registry = AtomRegistry.make(); | ||
| 25 | }); | ||
| 26 | |||
| 27 | function unresolvedBrowse() { | ||
| 28 | throw new Error("Browse resolver was not initialized."); | ||
| 29 | } | ||
| 30 | |||
| 31 | function flushAsyncWork(): Promise<void> { | ||
| 32 | return Promise.resolve().then(() => undefined); | ||
| 33 | } | ||
| 34 | |||
| 35 | it("stores browsed folder data in an atom snapshot", async () => { | ||
| 36 | const manager = createFilesystemBrowseManager({ | ||
| 37 | getRegistry: () => registry, | ||
| 38 | getClient: () => ({ | ||
| 39 | browse: async () => ROOT_RESULT, | ||
| 40 | }), | ||
| 41 | }); | ||
| 42 | |||
| 43 | assert.deepStrictEqual( | ||
| 44 | manager.getSnapshot({ key: null, input: null }), | ||
| 45 | EMPTY_FILESYSTEM_BROWSE_STATE, | ||
| 46 | ); | ||
| 47 | |||
| 48 | const target = { key: "env-1", input: { partialPath: "~" } }; | ||
| 49 | const result = await manager.refresh(target); | ||
| 50 | |||
| 51 | assert.strictEqual(result, ROOT_RESULT); | ||
| 52 | assert.deepStrictEqual(manager.getSnapshot(target), { | ||
| 53 | data: ROOT_RESULT, | ||
| 54 | error: null, | ||
| 55 | isPending: false, | ||
| 56 | }); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it("deduplicates in-flight browse refreshes by target input", async () => { | ||
| 60 | let resolveBrowse: (result: FilesystemBrowseResult) => void = unresolvedBrowse; | ||
| 61 | let calls = 0; | ||
| 62 | const target = { key: "env-1", input: { partialPath: "~" } }; | ||
| 63 | const manager = createFilesystemBrowseManager({ | ||
| 64 | getRegistry: () => registry, | ||
| 65 | getClient: () => ({ | ||
| 66 | browse: () => { | ||
| 67 | calls += 1; | ||
| 68 | return new Promise<FilesystemBrowseResult>((resolve) => { | ||
| 69 | resolveBrowse = resolve; | ||
| 70 | }); | ||
| 71 | }, | ||
| 72 | }), | ||
| 73 | }); | ||
| 74 | |||
| 75 | const first = manager.refresh(target); | ||
| 76 | const second = manager.refresh(target); | ||
| 77 | |||
| 78 | assert.strictEqual(first, second); | ||
| 79 | assert.strictEqual(calls, 1); | ||
| 80 | assert.deepStrictEqual(manager.getSnapshot(target), { | ||
| 81 | data: null, | ||
| 82 | error: null, | ||
| 83 | isPending: true, | ||
| 84 | }); | ||
| 85 | |||
| 86 | resolveBrowse(ROOT_RESULT); | ||
| 87 | await first; | ||
| 88 | |||
| 89 | assert.deepStrictEqual(manager.getSnapshot(target), { | ||
| 90 | data: ROOT_RESULT, | ||
| 91 | error: null, | ||
| 92 | isPending: false, | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("keeps fresh watched browse results on remount", async () => { | ||
| 97 | let browseCalls = 0; | ||
| 98 | const target = { key: "env-1", input: { partialPath: "~" } }; | ||
| 99 | const manager = createFilesystemBrowseManager({ | ||
| 100 | getRegistry: () => registry, | ||
| 101 | getClient: () => ({ | ||
| 102 | browse: async () => { | ||
| 103 | browseCalls += 1; | ||
| 104 | return ROOT_RESULT; | ||
| 105 | }, | ||
| 106 | }), | ||
| 107 | staleTimeMs: 60_000, | ||
| 108 | }); | ||
| 109 | |||
| 110 | const firstUnwatch = manager.watch(target); | ||
| 111 | await flushAsyncWork(); | ||
| 112 | firstUnwatch(); | ||
| 113 | |||
| 114 | const secondUnwatch = manager.watch(target); | ||
| 115 | await flushAsyncWork(); | ||
| 116 | secondUnwatch(); | ||
| 117 | |||
| 118 | assert.strictEqual(browseCalls, 1); | ||
| 119 | }); | ||
packages/client-runtime/src/filesystemBrowseState.ts created+339| ... | @@ -0,0 +1,339 @@ | ||
| 1 | import type { FilesystemBrowseInput, FilesystemBrowseResult } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | export interface FilesystemBrowseState { | ||
| 6 | readonly data: FilesystemBrowseResult | null; | ||
| 7 | readonly error: string | null; | ||
| 8 | readonly isPending: boolean; | ||
| 9 | } | ||
| 10 | |||
| 11 | export interface FilesystemBrowseTarget<TKey extends string = string> { | ||
| 12 | readonly key: TKey | null; | ||
| 13 | readonly input: FilesystemBrowseInput | null; | ||
| 14 | } | ||
| 15 | |||
| 16 | export interface FilesystemBrowseClient { | ||
| 17 | readonly browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>; | ||
| 18 | } | ||
| 19 | |||
| 20 | interface WatchedEntry { | ||
| 21 | refCount: number; | ||
| 22 | teardown: () => void; | ||
| 23 | } | ||
| 24 | |||
| 25 | export const EMPTY_FILESYSTEM_BROWSE_STATE = Object.freeze<FilesystemBrowseState>({ | ||
| 26 | data: null, | ||
| 27 | error: null, | ||
| 28 | isPending: false, | ||
| 29 | }); | ||
| 30 | |||
| 31 | const INITIAL_FILESYSTEM_BROWSE_STATE = Object.freeze<FilesystemBrowseState>({ | ||
| 32 | data: null, | ||
| 33 | error: null, | ||
| 34 | isPending: true, | ||
| 35 | }); | ||
| 36 | |||
| 37 | const knownFilesystemBrowseKeys = new Set<string>(); | ||
| 38 | |||
| 39 | export const filesystemBrowseStateAtom = Atom.family((targetKey: string) => { | ||
| 40 | knownFilesystemBrowseKeys.add(targetKey); | ||
| 41 | return Atom.make(INITIAL_FILESYSTEM_BROWSE_STATE).pipe( | ||
| 42 | Atom.keepAlive, | ||
| 43 | Atom.withLabel(`filesystem-browse:${targetKey}`), | ||
| 44 | ); | ||
| 45 | }); | ||
| 46 | |||
| 47 | export const EMPTY_FILESYSTEM_BROWSE_ATOM = Atom.make(EMPTY_FILESYSTEM_BROWSE_STATE).pipe( | ||
| 48 | Atom.keepAlive, | ||
| 49 | Atom.withLabel("filesystem-browse:null"), | ||
| 50 | ); | ||
| 51 | |||
| 52 | const NOOP: () => void = () => undefined; | ||
| 53 | const DEFAULT_STALE_TIME_MS = 30_000; | ||
| 54 | const DEFAULT_IDLE_TTL_MS = 5 * 60_000; | ||
| 55 | |||
| 56 | export function getFilesystemBrowseTargetKey<TKey extends string>( | ||
| 57 | target: FilesystemBrowseTarget<TKey>, | ||
| 58 | ): string | null { | ||
| 59 | const key = target.key; | ||
| 60 | const input = target.input; | ||
| 61 | if (!key || !input || input.partialPath.length === 0) { | ||
| 62 | return null; | ||
| 63 | } | ||
| 64 | |||
| 65 | return JSON.stringify([key, input.cwd ?? null, input.partialPath]); | ||
| 66 | } | ||
| 67 | |||
| 68 | export interface FilesystemBrowseManagerConfig<TKey extends string = string> { | ||
| 69 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 70 | readonly getClient: (key: TKey) => FilesystemBrowseClient | null; | ||
| 71 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 72 | readonly staleTimeMs?: number; | ||
| 73 | readonly idleTtlMs?: number; | ||
| 74 | } | ||
| 75 | |||
| 76 | export function createFilesystemBrowseManager<TKey extends string = string>( | ||
| 77 | config: FilesystemBrowseManagerConfig<TKey>, | ||
| 78 | ) { | ||
| 79 | const refreshInFlight = new Map< | ||
| 80 | string, | ||
| 81 | { | ||
| 82 | readonly client: FilesystemBrowseClient; | ||
| 83 | readonly promise: Promise<FilesystemBrowseResult | null>; | ||
| 84 | } | ||
| 85 | >(); | ||
| 86 | const refreshVersions = new Map<string, number>(); | ||
| 87 | const watched = new Map<string, WatchedEntry>(); | ||
| 88 | const refreshTargets = new Map<string, FilesystemBrowseTarget<TKey>>(); | ||
| 89 | const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS; | ||
| 90 | const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; | ||
| 91 | |||
| 92 | const watchedRefreshAtom = Atom.family((targetKey: string) => | ||
| 93 | Atom.make(() => | ||
| 94 | Effect.promise(() => { | ||
| 95 | const target = refreshTargets.get(targetKey); | ||
| 96 | return target ? refresh(target) : Promise.resolve(null); | ||
| 97 | }), | ||
| 98 | ).pipe( | ||
| 99 | Atom.swr({ | ||
| 100 | staleTime: staleTimeMs, | ||
| 101 | revalidateOnMount: true, | ||
| 102 | }), | ||
| 103 | Atom.setIdleTTL(idleTtlMs), | ||
| 104 | Atom.withLabel(`filesystem-browse:watched-refresh:${targetKey}`), | ||
| 105 | ), | ||
| 106 | ); | ||
| 107 | |||
| 108 | function getRefreshVersion(targetKey: string): number { | ||
| 109 | return refreshVersions.get(targetKey) ?? 0; | ||
| 110 | } | ||
| 111 | |||
| 112 | function bumpRefreshVersion(targetKey: string): void { | ||
| 113 | refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1); | ||
| 114 | } | ||
| 115 | |||
| 116 | function setState(targetKey: string, nextState: FilesystemBrowseState): void { | ||
| 117 | config.getRegistry().set(filesystemBrowseStateAtom(targetKey), nextState); | ||
| 118 | } | ||
| 119 | |||
| 120 | function markPending(targetKey: string): void { | ||
| 121 | const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); | ||
| 122 | const next: FilesystemBrowseState = | ||
| 123 | current.data === null | ||
| 124 | ? INITIAL_FILESYSTEM_BROWSE_STATE | ||
| 125 | : { | ||
| 126 | data: current.data, | ||
| 127 | error: null, | ||
| 128 | isPending: true, | ||
| 129 | }; | ||
| 130 | |||
| 131 | if ( | ||
| 132 | current.data === next.data && | ||
| 133 | current.error === next.error && | ||
| 134 | current.isPending === next.isPending | ||
| 135 | ) { | ||
| 136 | return; | ||
| 137 | } | ||
| 138 | |||
| 139 | setState(targetKey, next); | ||
| 140 | } | ||
| 141 | |||
| 142 | function setData(targetKey: string, data: FilesystemBrowseResult): void { | ||
| 143 | setState(targetKey, { | ||
| 144 | data, | ||
| 145 | error: null, | ||
| 146 | isPending: false, | ||
| 147 | }); | ||
| 148 | } | ||
| 149 | |||
| 150 | function setError(targetKey: string, error: unknown): void { | ||
| 151 | const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); | ||
| 152 | setState(targetKey, { | ||
| 153 | data: current.data, | ||
| 154 | error: error instanceof Error ? error.message : "Failed to browse folder.", | ||
| 155 | isPending: false, | ||
| 156 | }); | ||
| 157 | } | ||
| 158 | |||
| 159 | function refresh( | ||
| 160 | target: FilesystemBrowseTarget<TKey>, | ||
| 161 | client?: FilesystemBrowseClient, | ||
| 162 | ): Promise<FilesystemBrowseResult | null> { | ||
| 163 | const targetKey = getFilesystemBrowseTargetKey(target); | ||
| 164 | if (targetKey === null || target.key === null || target.input === null) { | ||
| 165 | return Promise.resolve(null); | ||
| 166 | } | ||
| 167 | refreshTargets.set(targetKey, target); | ||
| 168 | |||
| 169 | const resolvedClient = client ?? config.getClient(target.key); | ||
| 170 | if (!resolvedClient) { | ||
| 171 | setError(targetKey, new Error("Filesystem browser client is unavailable.")); | ||
| 172 | return Promise.resolve(getSnapshot(target).data); | ||
| 173 | } | ||
| 174 | |||
| 175 | const existing = refreshInFlight.get(targetKey); | ||
| 176 | if (existing) { | ||
| 177 | if (!client || existing.client === resolvedClient) { | ||
| 178 | return existing.promise; | ||
| 179 | } | ||
| 180 | return existing.promise.then(() => refresh(target, resolvedClient)); | ||
| 181 | } | ||
| 182 | |||
| 183 | markPending(targetKey); | ||
| 184 | const refreshVersion = getRefreshVersion(targetKey); | ||
| 185 | const promise = resolvedClient.browse(target.input).then( | ||
| 186 | (result) => { | ||
| 187 | if (getRefreshVersion(targetKey) === refreshVersion) { | ||
| 188 | setData(targetKey, result); | ||
| 189 | } | ||
| 190 | return result; | ||
| 191 | }, | ||
| 192 | (error: unknown) => { | ||
| 193 | if (getRefreshVersion(targetKey) === refreshVersion) { | ||
| 194 | setError(targetKey, error); | ||
| 195 | } | ||
| 196 | return getSnapshot(target).data; | ||
| 197 | }, | ||
| 198 | ); | ||
| 199 | |||
| 200 | let tracked: Promise<FilesystemBrowseResult | null>; | ||
| 201 | tracked = promise.finally(() => { | ||
| 202 | if (refreshInFlight.get(targetKey)?.promise === tracked) { | ||
| 203 | refreshInFlight.delete(targetKey); | ||
| 204 | } | ||
| 205 | }); | ||
| 206 | refreshInFlight.set(targetKey, { | ||
| 207 | client: resolvedClient, | ||
| 208 | promise: tracked, | ||
| 209 | }); | ||
| 210 | return tracked; | ||
| 211 | } | ||
| 212 | |||
| 213 | function invalidate(target?: FilesystemBrowseTarget<TKey>): void { | ||
| 214 | if (!target) { | ||
| 215 | reset(); | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | |||
| 219 | const targetKey = getFilesystemBrowseTargetKey(target); | ||
| 220 | if (targetKey === null) { | ||
| 221 | return; | ||
| 222 | } | ||
| 223 | |||
| 224 | bumpRefreshVersion(targetKey); | ||
| 225 | refreshInFlight.delete(targetKey); | ||
| 226 | setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE); | ||
| 227 | } | ||
| 228 | |||
| 229 | function getSnapshot(target: FilesystemBrowseTarget<TKey>): FilesystemBrowseState { | ||
| 230 | const targetKey = getFilesystemBrowseTargetKey(target); | ||
| 231 | if (targetKey === null) { | ||
| 232 | return EMPTY_FILESYSTEM_BROWSE_STATE; | ||
| 233 | } | ||
| 234 | |||
| 235 | return config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); | ||
| 236 | } | ||
| 237 | |||
| 238 | function watch( | ||
| 239 | target: FilesystemBrowseTarget<TKey>, | ||
| 240 | client?: FilesystemBrowseClient, | ||
| 241 | ): () => void { | ||
| 242 | const targetKey = getFilesystemBrowseTargetKey(target); | ||
| 243 | if (targetKey === null || target.key === null) { | ||
| 244 | return NOOP; | ||
| 245 | } | ||
| 246 | refreshTargets.set(targetKey, target); | ||
| 247 | |||
| 248 | const existing = watched.get(targetKey); | ||
| 249 | if (existing) { | ||
| 250 | existing.refCount += 1; | ||
| 251 | return () => unwatch(targetKey); | ||
| 252 | } | ||
| 253 | |||
| 254 | let teardown: () => void; | ||
| 255 | |||
| 256 | if (client) { | ||
| 257 | void refresh(target, client); | ||
| 258 | teardown = NOOP; | ||
| 259 | } else if (config.subscribeClientChanges) { | ||
| 260 | let currentClient: FilesystemBrowseClient | null = null; | ||
| 261 | |||
| 262 | const sync = () => { | ||
| 263 | const resolved = config.getClient(target.key!); | ||
| 264 | if (!resolved) { | ||
| 265 | currentClient = null; | ||
| 266 | markPending(targetKey); | ||
| 267 | return; | ||
| 268 | } | ||
| 269 | |||
| 270 | if (currentClient === resolved) { | ||
| 271 | return; | ||
| 272 | } | ||
| 273 | |||
| 274 | const isClientReplacement = currentClient !== null; | ||
| 275 | currentClient = resolved; | ||
| 276 | refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined); | ||
| 277 | }; | ||
| 278 | |||
| 279 | const unsubChanges = config.subscribeClientChanges(sync); | ||
| 280 | sync(); | ||
| 281 | teardown = unsubChanges; | ||
| 282 | } else { | ||
| 283 | if (!config.getClient(target.key)) { | ||
| 284 | return NOOP; | ||
| 285 | } | ||
| 286 | refreshWatchedTarget(targetKey, target); | ||
| 287 | teardown = NOOP; | ||
| 288 | } | ||
| 289 | |||
| 290 | watched.set(targetKey, { refCount: 1, teardown }); | ||
| 291 | return () => unwatch(targetKey); | ||
| 292 | } | ||
| 293 | |||
| 294 | function unwatch(targetKey: string): void { | ||
| 295 | const entry = watched.get(targetKey); | ||
| 296 | if (!entry) { | ||
| 297 | return; | ||
| 298 | } | ||
| 299 | |||
| 300 | entry.refCount -= 1; | ||
| 301 | if (entry.refCount > 0) { | ||
| 302 | return; | ||
| 303 | } | ||
| 304 | |||
| 305 | entry.teardown(); | ||
| 306 | watched.delete(targetKey); | ||
| 307 | } | ||
| 308 | |||
| 309 | function refreshWatchedTarget( | ||
| 310 | targetKey: string, | ||
| 311 | target: FilesystemBrowseTarget<TKey>, | ||
| 312 | client?: FilesystemBrowseClient, | ||
| 313 | ): void { | ||
| 314 | refreshTargets.set(targetKey, target); | ||
| 315 | const registry = config.getRegistry(); | ||
| 316 | void registry.get(watchedRefreshAtom(targetKey)); | ||
| 317 | if (client) { | ||
| 318 | void refresh(target, client); | ||
| 319 | } | ||
| 320 | } | ||
| 321 | |||
| 322 | function reset(): void { | ||
| 323 | refreshInFlight.clear(); | ||
| 324 | watched.clear(); | ||
| 325 | refreshTargets.clear(); | ||
| 326 | for (const targetKey of knownFilesystemBrowseKeys) { | ||
| 327 | bumpRefreshVersion(targetKey); | ||
| 328 | setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE); | ||
| 329 | } | ||
| 330 | } | ||
| 331 | |||
| 332 | return { | ||
| 333 | refresh, | ||
| 334 | invalidate, | ||
| 335 | getSnapshot, | ||
| 336 | watch, | ||
| 337 | reset, | ||
| 338 | }; | ||
| 339 | } | ||
packages/client-runtime/src/gitActions.ts created+440| ... | @@ -0,0 +1,440 @@ | ||
| 1 | import type { | ||
| 2 | GitRunStackedActionInput, | ||
| 3 | GitRunStackedActionResult, | ||
| 4 | GitStackedAction, | ||
| 5 | VcsStatusResult, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; | ||
| 8 | |||
| 9 | export type GitActionIconName = "commit" | "push" | "pr"; | ||
| 10 | |||
| 11 | export type GitDialogAction = "commit" | "push" | "create_pr"; | ||
| 12 | |||
| 13 | export interface GitActionMenuItem { | ||
| 14 | id: "commit" | "push" | "pr"; | ||
| 15 | label: string; | ||
| 16 | disabled: boolean; | ||
| 17 | icon: GitActionIconName; | ||
| 18 | kind: "open_dialog" | "open_pr"; | ||
| 19 | dialogAction?: GitDialogAction; | ||
| 20 | } | ||
| 21 | |||
| 22 | export interface GitQuickAction { | ||
| 23 | label: string; | ||
| 24 | disabled: boolean; | ||
| 25 | kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; | ||
| 26 | action?: GitStackedAction; | ||
| 27 | hint?: string; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface DefaultBranchActionDialogCopy { | ||
| 31 | title: string; | ||
| 32 | description: string; | ||
| 33 | continueLabel: string; | ||
| 34 | } | ||
| 35 | |||
| 36 | export type DefaultBranchConfirmableAction = | ||
| 37 | | "push" | ||
| 38 | | "create_pr" | ||
| 39 | | "commit_push" | ||
| 40 | | "commit_push_pr"; | ||
| 41 | |||
| 42 | export type GitActionRequestInput = Pick< | ||
| 43 | GitRunStackedActionInput, | ||
| 44 | "action" | "commitMessage" | "featureBranch" | "filePaths" | ||
| 45 | >; | ||
| 46 | |||
| 47 | export function buildGitActionProgressStages(input: { | ||
| 48 | action: GitStackedAction; | ||
| 49 | hasCustomCommitMessage: boolean; | ||
| 50 | hasWorkingTreeChanges: boolean; | ||
| 51 | pushTarget?: string; | ||
| 52 | featureBranch?: boolean; | ||
| 53 | shouldPushBeforePr?: boolean; | ||
| 54 | }): string[] { | ||
| 55 | const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; | ||
| 56 | const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; | ||
| 57 | const prStages = [ | ||
| 58 | "Preparing PR...", | ||
| 59 | "Generating PR content...", | ||
| 60 | "Creating GitHub pull request...", | ||
| 61 | ]; | ||
| 62 | |||
| 63 | if (input.action === "push") { | ||
| 64 | return [pushStage]; | ||
| 65 | } | ||
| 66 | if (input.action === "create_pr") { | ||
| 67 | return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages; | ||
| 68 | } | ||
| 69 | |||
| 70 | const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; | ||
| 71 | const commitStages = !shouldIncludeCommitStages | ||
| 72 | ? [] | ||
| 73 | : input.hasCustomCommitMessage | ||
| 74 | ? ["Committing..."] | ||
| 75 | : ["Generating commit message...", "Committing..."]; | ||
| 76 | if (input.action === "commit") { | ||
| 77 | return [...branchStages, ...commitStages]; | ||
| 78 | } | ||
| 79 | if (input.action === "commit_push") { | ||
| 80 | return [...branchStages, ...commitStages, pushStage]; | ||
| 81 | } | ||
| 82 | return [...branchStages, ...commitStages, pushStage, ...prStages]; | ||
| 83 | } | ||
| 84 | |||
| 85 | export function buildMenuItems( | ||
| 86 | gitStatus: VcsStatusResult | null, | ||
| 87 | isBusy: boolean, | ||
| 88 | hasOriginRemote = true, | ||
| 89 | ): GitActionMenuItem[] { | ||
| 90 | if (!gitStatus) return []; | ||
| 91 | |||
| 92 | const hasBranch = gitStatus.refName !== null; | ||
| 93 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 94 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 95 | const isBehind = gitStatus.behindCount > 0; | ||
| 96 | const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; | ||
| 97 | const canCommit = !isBusy && hasChanges; | ||
| 98 | const canPush = | ||
| 99 | !isBusy && | ||
| 100 | hasBranch && | ||
| 101 | !hasChanges && | ||
| 102 | !isBehind && | ||
| 103 | gitStatus.aheadCount > 0 && | ||
| 104 | (gitStatus.hasUpstream || canPushWithoutUpstream); | ||
| 105 | const canCreatePr = | ||
| 106 | !isBusy && | ||
| 107 | hasBranch && | ||
| 108 | !hasChanges && | ||
| 109 | !hasOpenPr && | ||
| 110 | gitStatus.aheadCount > 0 && | ||
| 111 | !isBehind && | ||
| 112 | (gitStatus.hasUpstream || canPushWithoutUpstream); | ||
| 113 | const canOpenPr = !isBusy && hasOpenPr; | ||
| 114 | |||
| 115 | return [ | ||
| 116 | { | ||
| 117 | id: "commit", | ||
| 118 | label: "Commit", | ||
| 119 | disabled: !canCommit, | ||
| 120 | icon: "commit", | ||
| 121 | kind: "open_dialog", | ||
| 122 | dialogAction: "commit", | ||
| 123 | }, | ||
| 124 | { | ||
| 125 | id: "push", | ||
| 126 | label: "Push", | ||
| 127 | disabled: !canPush, | ||
| 128 | icon: "push", | ||
| 129 | kind: "open_dialog", | ||
| 130 | dialogAction: "push", | ||
| 131 | }, | ||
| 132 | hasOpenPr | ||
| 133 | ? { | ||
| 134 | id: "pr", | ||
| 135 | label: "View PR", | ||
| 136 | disabled: !canOpenPr, | ||
| 137 | icon: "pr", | ||
| 138 | kind: "open_pr", | ||
| 139 | } | ||
| 140 | : { | ||
| 141 | id: "pr", | ||
| 142 | label: "Create PR", | ||
| 143 | disabled: !canCreatePr, | ||
| 144 | icon: "pr", | ||
| 145 | kind: "open_dialog", | ||
| 146 | dialogAction: "create_pr", | ||
| 147 | }, | ||
| 148 | ]; | ||
| 149 | } | ||
| 150 | |||
| 151 | export function resolveQuickAction( | ||
| 152 | gitStatus: VcsStatusResult | null, | ||
| 153 | isBusy: boolean, | ||
| 154 | isDefaultBranch = false, | ||
| 155 | hasOriginRemote = true, | ||
| 156 | ): GitQuickAction { | ||
| 157 | if (isBusy) { | ||
| 158 | return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; | ||
| 159 | } | ||
| 160 | |||
| 161 | if (!gitStatus) { | ||
| 162 | return { | ||
| 163 | label: "Commit", | ||
| 164 | disabled: true, | ||
| 165 | kind: "show_hint", | ||
| 166 | hint: "Git status is unavailable.", | ||
| 167 | }; | ||
| 168 | } | ||
| 169 | |||
| 170 | const hasBranch = gitStatus.refName !== null; | ||
| 171 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 172 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 173 | const isAhead = gitStatus.aheadCount > 0; | ||
| 174 | const isBehind = gitStatus.behindCount > 0; | ||
| 175 | const isDiverged = isAhead && isBehind; | ||
| 176 | |||
| 177 | if (!hasBranch) { | ||
| 178 | return { | ||
| 179 | label: "Commit", | ||
| 180 | disabled: true, | ||
| 181 | kind: "show_hint", | ||
| 182 | hint: "Create and checkout a branch before pushing or opening a PR.", | ||
| 183 | }; | ||
| 184 | } | ||
| 185 | |||
| 186 | if (hasChanges) { | ||
| 187 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 188 | return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; | ||
| 189 | } | ||
| 190 | if (hasOpenPr || isDefaultBranch) { | ||
| 191 | return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; | ||
| 192 | } | ||
| 193 | return { | ||
| 194 | label: "Commit, push & PR", | ||
| 195 | disabled: false, | ||
| 196 | kind: "run_action", | ||
| 197 | action: "commit_push_pr", | ||
| 198 | }; | ||
| 199 | } | ||
| 200 | |||
| 201 | if (!gitStatus.hasUpstream) { | ||
| 202 | if (!hasOriginRemote) { | ||
| 203 | if (hasOpenPr && !isAhead) { | ||
| 204 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 205 | } | ||
| 206 | return { | ||
| 207 | label: "Push", | ||
| 208 | disabled: true, | ||
| 209 | kind: "show_hint", | ||
| 210 | hint: 'Add an "origin" remote before pushing or creating a PR.', | ||
| 211 | }; | ||
| 212 | } | ||
| 213 | if (!isAhead) { | ||
| 214 | if (hasOpenPr) { | ||
| 215 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 216 | } | ||
| 217 | return { | ||
| 218 | label: "Push", | ||
| 219 | disabled: true, | ||
| 220 | kind: "show_hint", | ||
| 221 | hint: "No local commits to push.", | ||
| 222 | }; | ||
| 223 | } | ||
| 224 | if (hasOpenPr || isDefaultBranch) { | ||
| 225 | return { | ||
| 226 | label: "Push", | ||
| 227 | disabled: false, | ||
| 228 | kind: "run_action", | ||
| 229 | action: isDefaultBranch ? "commit_push" : "push", | ||
| 230 | }; | ||
| 231 | } | ||
| 232 | return { | ||
| 233 | label: "Push & create PR", | ||
| 234 | disabled: false, | ||
| 235 | kind: "run_action", | ||
| 236 | action: "create_pr", | ||
| 237 | }; | ||
| 238 | } | ||
| 239 | |||
| 240 | if (isDiverged) { | ||
| 241 | return { | ||
| 242 | label: "Sync branch", | ||
| 243 | disabled: true, | ||
| 244 | kind: "show_hint", | ||
| 245 | hint: "Branch has diverged from upstream. Rebase/merge first.", | ||
| 246 | }; | ||
| 247 | } | ||
| 248 | |||
| 249 | if (isBehind) { | ||
| 250 | return { | ||
| 251 | label: "Pull", | ||
| 252 | disabled: false, | ||
| 253 | kind: "run_pull", | ||
| 254 | }; | ||
| 255 | } | ||
| 256 | |||
| 257 | if (isAhead) { | ||
| 258 | if (hasOpenPr || isDefaultBranch) { | ||
| 259 | return { | ||
| 260 | label: "Push", | ||
| 261 | disabled: false, | ||
| 262 | kind: "run_action", | ||
| 263 | action: isDefaultBranch ? "commit_push" : "push", | ||
| 264 | }; | ||
| 265 | } | ||
| 266 | return { | ||
| 267 | label: "Push & create PR", | ||
| 268 | disabled: false, | ||
| 269 | kind: "run_action", | ||
| 270 | action: "create_pr", | ||
| 271 | }; | ||
| 272 | } | ||
| 273 | |||
| 274 | if (hasOpenPr && gitStatus.hasUpstream) { | ||
| 275 | return { label: "View PR", disabled: false, kind: "open_pr" }; | ||
| 276 | } | ||
| 277 | |||
| 278 | return { | ||
| 279 | label: "Commit", | ||
| 280 | disabled: true, | ||
| 281 | kind: "show_hint", | ||
| 282 | hint: "Branch is up to date. No action needed.", | ||
| 283 | }; | ||
| 284 | } | ||
| 285 | |||
| 286 | export function getGitActionDisabledReason(input: { | ||
| 287 | item: GitActionMenuItem; | ||
| 288 | gitStatus: VcsStatusResult | null; | ||
| 289 | isBusy: boolean; | ||
| 290 | hasOriginRemote: boolean; | ||
| 291 | }): string | null { | ||
| 292 | const { item, gitStatus, isBusy, hasOriginRemote } = input; | ||
| 293 | if (!item.disabled) return null; | ||
| 294 | if (isBusy) return "Git action in progress."; | ||
| 295 | if (!gitStatus) return "Git status is unavailable."; | ||
| 296 | |||
| 297 | const hasBranch = gitStatus.refName !== null; | ||
| 298 | const hasChanges = gitStatus.hasWorkingTreeChanges; | ||
| 299 | const hasOpenPr = gitStatus.pr?.state === "open"; | ||
| 300 | const isAhead = gitStatus.aheadCount > 0; | ||
| 301 | const isBehind = gitStatus.behindCount > 0; | ||
| 302 | |||
| 303 | if (item.id === "commit") { | ||
| 304 | if (!hasChanges) { | ||
| 305 | return "Worktree is clean. Make changes before committing."; | ||
| 306 | } | ||
| 307 | return "Commit is currently unavailable."; | ||
| 308 | } | ||
| 309 | |||
| 310 | if (item.id === "push") { | ||
| 311 | if (!hasBranch) { | ||
| 312 | return "Detached HEAD: checkout a branch before pushing."; | ||
| 313 | } | ||
| 314 | if (hasChanges) { | ||
| 315 | return "Commit or stash local changes before pushing."; | ||
| 316 | } | ||
| 317 | if (isBehind) { | ||
| 318 | return "Branch is behind upstream. Pull/rebase before pushing."; | ||
| 319 | } | ||
| 320 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 321 | return 'Add an "origin" remote before pushing.'; | ||
| 322 | } | ||
| 323 | if (!isAhead) { | ||
| 324 | return "No local commits to push."; | ||
| 325 | } | ||
| 326 | return "Push is currently unavailable."; | ||
| 327 | } | ||
| 328 | |||
| 329 | if (hasOpenPr) { | ||
| 330 | return "View PR is currently unavailable."; | ||
| 331 | } | ||
| 332 | if (!hasBranch) { | ||
| 333 | return "Detached HEAD: checkout a branch before creating a PR."; | ||
| 334 | } | ||
| 335 | if (hasChanges) { | ||
| 336 | return "Commit local changes before creating a PR."; | ||
| 337 | } | ||
| 338 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | ||
| 339 | return 'Add an "origin" remote before creating a PR.'; | ||
| 340 | } | ||
| 341 | if (!isAhead) { | ||
| 342 | return "No local commits to include in a PR."; | ||
| 343 | } | ||
| 344 | if (isBehind) { | ||
| 345 | return "Branch is behind upstream. Pull/rebase before creating a PR."; | ||
| 346 | } | ||
| 347 | return "Create PR is currently unavailable."; | ||
| 348 | } | ||
| 349 | |||
| 350 | export function requiresDefaultBranchConfirmation( | ||
| 351 | action: GitStackedAction, | ||
| 352 | isDefaultBranch: boolean, | ||
| 353 | ): boolean { | ||
| 354 | if (!isDefaultBranch) return false; | ||
| 355 | return ( | ||
| 356 | action === "push" || | ||
| 357 | action === "create_pr" || | ||
| 358 | action === "commit_push" || | ||
| 359 | action === "commit_push_pr" | ||
| 360 | ); | ||
| 361 | } | ||
| 362 | |||
| 363 | export function resolveDefaultBranchActionDialogCopy(input: { | ||
| 364 | action: DefaultBranchConfirmableAction; | ||
| 365 | branchName: string; | ||
| 366 | includesCommit: boolean; | ||
| 367 | }): DefaultBranchActionDialogCopy { | ||
| 368 | const branchLabel = input.branchName; | ||
| 369 | const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; | ||
| 370 | |||
| 371 | if (input.action === "push" || input.action === "commit_push") { | ||
| 372 | if (input.includesCommit) { | ||
| 373 | return { | ||
| 374 | title: "Commit & push to default branch?", | ||
| 375 | description: `This action will commit and push changes${suffix}`, | ||
| 376 | continueLabel: `Commit & push to ${branchLabel}`, | ||
| 377 | }; | ||
| 378 | } | ||
| 379 | return { | ||
| 380 | title: "Push to default branch?", | ||
| 381 | description: `This action will push local commits${suffix}`, | ||
| 382 | continueLabel: `Push to ${branchLabel}`, | ||
| 383 | }; | ||
| 384 | } | ||
| 385 | |||
| 386 | if (input.includesCommit) { | ||
| 387 | return { | ||
| 388 | title: "Commit, push & create PR from default branch?", | ||
| 389 | description: `This action will commit, push, and create a PR${suffix}`, | ||
| 390 | continueLabel: "Commit, push & create PR", | ||
| 391 | }; | ||
| 392 | } | ||
| 393 | return { | ||
| 394 | title: "Push & create PR from default branch?", | ||
| 395 | description: `This action will push local commits and create a PR${suffix}`, | ||
| 396 | continueLabel: "Push & create PR", | ||
| 397 | }; | ||
| 398 | } | ||
| 399 | |||
| 400 | export function resolveThreadBranchUpdate( | ||
| 401 | result: GitRunStackedActionResult, | ||
| 402 | ): { branch: string } | null { | ||
| 403 | if (result.branch.status !== "created" || !result.branch.name) { | ||
| 404 | return null; | ||
| 405 | } | ||
| 406 | |||
| 407 | return { | ||
| 408 | branch: result.branch.name, | ||
| 409 | }; | ||
| 410 | } | ||
| 411 | |||
| 412 | export function resolveLiveThreadBranchUpdate(input: { | ||
| 413 | threadBranch: string | null; | ||
| 414 | gitStatus: VcsStatusResult | null; | ||
| 415 | }): { branch: string | null } | null { | ||
| 416 | if (!input.gitStatus) { | ||
| 417 | return null; | ||
| 418 | } | ||
| 419 | |||
| 420 | if (input.gitStatus.refName === null && input.threadBranch !== null) { | ||
| 421 | return null; | ||
| 422 | } | ||
| 423 | |||
| 424 | if (input.threadBranch === input.gitStatus.refName) { | ||
| 425 | return null; | ||
| 426 | } | ||
| 427 | |||
| 428 | if ( | ||
| 429 | input.threadBranch !== null && | ||
| 430 | input.gitStatus.refName !== null && | ||
| 431 | !isTemporaryWorktreeBranch(input.threadBranch) && | ||
| 432 | isTemporaryWorktreeBranch(input.gitStatus.refName) | ||
| 433 | ) { | ||
| 434 | return null; | ||
| 435 | } | ||
| 436 | |||
| 437 | return { | ||
| 438 | branch: input.gitStatus.refName, | ||
| 439 | }; | ||
| 440 | } | ||
packages/client-runtime/src/index.ts+24| ... | @@ -1,4 +1,28 @@ | ... | @@ -1,4 +1,28 @@ |
| 1 | export * from "./advertisedEndpoint.ts"; | 1 | export * from "./advertisedEndpoint.ts"; |
| 2 | export * from "./knownEnvironment.ts"; | 2 | export * from "./knownEnvironment.ts"; |
| 3 | export * from "./reconnectBackoff.ts"; | ||
| 3 | export * from "./scoped.ts"; | 4 | export * from "./scoped.ts"; |
| 5 | export * from "./projectPaths.ts"; | ||
| 6 | export * from "./addProject.ts"; | ||
| 7 | export * from "./filesystemBrowseState.ts"; | ||
| 4 | export * from "./sourceControlDiscoveryState.ts"; | 8 | export * from "./sourceControlDiscoveryState.ts"; |
| 9 | export * from "./environmentRuntimeState.ts"; | ||
| 10 | export * from "./shellTypes.ts"; | ||
| 11 | export * from "./shellSnapshotReducer.ts"; | ||
| 12 | export * from "./shellSnapshotState.ts"; | ||
| 13 | export * from "./threadDetailReducer.ts"; | ||
| 14 | export * from "./threadDetailState.ts"; | ||
| 15 | export * from "./gitActions.ts"; | ||
| 16 | export * from "./vcsActionState.ts"; | ||
| 17 | export * from "./vcsRefState.ts"; | ||
| 18 | export * from "./vcsStatusState.ts"; | ||
| 19 | export * from "./terminalSessionState.ts"; | ||
| 20 | export * from "./transportError.ts"; | ||
| 21 | export * from "./wsRpcProtocol.ts"; | ||
| 22 | export * from "./wsTransport.ts"; | ||
| 23 | export * from "./wsRpcClient.ts"; | ||
| 24 | export * from "./environmentConnection.ts"; | ||
| 25 | export * from "./composerPathSearchState.ts"; | ||
| 26 | export * from "./archivedThreadsState.ts"; | ||
| 27 | export * from "./checkpointDiffState.ts"; | ||
| 28 | export * from "./remote.ts"; |
packages/client-runtime/src/projectPaths.ts created+221| ... | @@ -0,0 +1,221 @@ | ||
| 1 | import { | ||
| 2 | isExplicitRelativePath, | ||
| 3 | isUncPath, | ||
| 4 | isWindowsAbsolutePath, | ||
| 5 | isWindowsDrivePath, | ||
| 6 | } from "@t3tools/shared/path"; | ||
| 7 | |||
| 8 | function isWindowsPlatform(platform: string): boolean { | ||
| 9 | return /^win(dows)?/i.test(platform); | ||
| 10 | } | ||
| 11 | |||
| 12 | function isRootPath(value: string): boolean { | ||
| 13 | return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); | ||
| 14 | } | ||
| 15 | |||
| 16 | function getAbsolutePathKind(value: string): "unix" | "windows" | null { | ||
| 17 | if (isWindowsDrivePath(value) || isUncPath(value)) { | ||
| 18 | return "windows"; | ||
| 19 | } | ||
| 20 | if (value.startsWith("/")) { | ||
| 21 | return "unix"; | ||
| 22 | } | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | |||
| 26 | function trimTrailingPathSeparators(value: string): string { | ||
| 27 | if (value.length === 0 || isRootPath(value)) { | ||
| 28 | return value; | ||
| 29 | } | ||
| 30 | const trimmed = | ||
| 31 | getAbsolutePathKind(value) === "unix" | ||
| 32 | ? value.replace(/\/+$/g, "") | ||
| 33 | : value.replace(/[\\/]+$/g, ""); | ||
| 34 | if (trimmed.length === 0) { | ||
| 35 | return value; | ||
| 36 | } | ||
| 37 | return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; | ||
| 38 | } | ||
| 39 | |||
| 40 | function preferredPathSeparator(value: string): "/" | "\\" { | ||
| 41 | const absolutePathKind = getAbsolutePathKind(value); | ||
| 42 | if (absolutePathKind === "windows") return "\\"; | ||
| 43 | if (absolutePathKind === "unix") return "/"; | ||
| 44 | return value.includes("\\") ? "\\" : "/"; | ||
| 45 | } | ||
| 46 | |||
| 47 | export function hasTrailingPathSeparator(value: string): boolean { | ||
| 48 | return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value); | ||
| 49 | } | ||
| 50 | |||
| 51 | export { isExplicitRelativePath as isExplicitRelativeProjectPath }; | ||
| 52 | |||
| 53 | function splitPathSegments(value: string, separator: "/" | "\\"): string[] { | ||
| 54 | return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean); | ||
| 55 | } | ||
| 56 | |||
| 57 | function getLastPathSeparatorIndex(value: string): number { | ||
| 58 | if (getAbsolutePathKind(value) === "unix") { | ||
| 59 | return value.lastIndexOf("/"); | ||
| 60 | } | ||
| 61 | return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); | ||
| 62 | } | ||
| 63 | |||
| 64 | function splitAbsolutePath(value: string): { | ||
| 65 | root: string; | ||
| 66 | separator: "/" | "\\"; | ||
| 67 | segments: string[]; | ||
| 68 | } | null { | ||
| 69 | if (isWindowsDrivePath(value)) { | ||
| 70 | const root = `${value.slice(0, 2)}\\`; | ||
| 71 | const segments = splitPathSegments(value.slice(root.length), "\\"); | ||
| 72 | return { root, separator: "\\", segments }; | ||
| 73 | } | ||
| 74 | if (isUncPath(value)) { | ||
| 75 | const segments = splitPathSegments(value, "\\"); | ||
| 76 | const [server, share, ...rest] = segments; | ||
| 77 | if (!server || !share) return null; | ||
| 78 | return { | ||
| 79 | root: `\\\\${server}\\${share}\\`, | ||
| 80 | separator: "\\", | ||
| 81 | segments: rest, | ||
| 82 | }; | ||
| 83 | } | ||
| 84 | if (value.startsWith("/")) { | ||
| 85 | return { | ||
| 86 | root: "/", | ||
| 87 | separator: "/", | ||
| 88 | segments: splitPathSegments(value.slice(1), "/"), | ||
| 89 | }; | ||
| 90 | } | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | |||
| 94 | export function isFilesystemBrowseQuery(value: string, platform = ""): boolean { | ||
| 95 | const allowWindowsPaths = isWindowsPlatform(platform); | ||
| 96 | return ( | ||
| 97 | value.startsWith("./") || | ||
| 98 | value.startsWith("../") || | ||
| 99 | value.startsWith(".\\") || | ||
| 100 | value.startsWith("..\\") || | ||
| 101 | value.startsWith("/") || | ||
| 102 | value.startsWith("~/") || | ||
| 103 | (allowWindowsPaths && isWindowsAbsolutePath(value)) | ||
| 104 | ); | ||
| 105 | } | ||
| 106 | |||
| 107 | export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean { | ||
| 108 | return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); | ||
| 109 | } | ||
| 110 | |||
| 111 | export function normalizeProjectPathForDispatch(value: string): string { | ||
| 112 | return trimTrailingPathSeparators(value.trim()); | ||
| 113 | } | ||
| 114 | |||
| 115 | export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { | ||
| 116 | const trimmedValue = value.trim(); | ||
| 117 | if (!isExplicitRelativePath(trimmedValue) || !cwd) { | ||
| 118 | return normalizeProjectPathForDispatch(trimmedValue); | ||
| 119 | } | ||
| 120 | |||
| 121 | const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd)); | ||
| 122 | if (!absoluteBase) { | ||
| 123 | return normalizeProjectPathForDispatch(trimmedValue); | ||
| 124 | } | ||
| 125 | |||
| 126 | const nextSegments = [...absoluteBase.segments]; | ||
| 127 | for (const segment of trimmedValue.split(/[\\/]+/)) { | ||
| 128 | if (segment.length === 0 || segment === ".") continue; | ||
| 129 | if (segment === "..") { | ||
| 130 | nextSegments.pop(); | ||
| 131 | continue; | ||
| 132 | } | ||
| 133 | nextSegments.push(segment); | ||
| 134 | } | ||
| 135 | |||
| 136 | const joinedPath = nextSegments.join(absoluteBase.separator); | ||
| 137 | return normalizeProjectPathForDispatch( | ||
| 138 | joinedPath.length === 0 ? absoluteBase.root : `${absoluteBase.root}${joinedPath}`, | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | |||
| 142 | export function normalizeProjectPathForComparison(value: string): string { | ||
| 143 | const normalized = normalizeProjectPathForDispatch(value); | ||
| 144 | if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { | ||
| 145 | return normalized.replaceAll("/", "\\").toLowerCase(); | ||
| 146 | } | ||
| 147 | return normalized; | ||
| 148 | } | ||
| 149 | |||
| 150 | export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: string }>( | ||
| 151 | projects: ReadonlyArray<T>, | ||
| 152 | candidatePath: string, | ||
| 153 | ): T | undefined { | ||
| 154 | const normalizedCandidate = normalizeProjectPathForComparison(candidatePath); | ||
| 155 | if (normalizedCandidate.length === 0) { | ||
| 156 | return undefined; | ||
| 157 | } | ||
| 158 | return projects.find((project) => { | ||
| 159 | const cwd = project.workspaceRoot ?? project.cwd; | ||
| 160 | return cwd ? normalizeProjectPathForComparison(cwd) === normalizedCandidate : false; | ||
| 161 | }); | ||
| 162 | } | ||
| 163 | |||
| 164 | export function inferProjectTitleFromPath(value: string): string { | ||
| 165 | const normalized = normalizeProjectPathForDispatch(value); | ||
| 166 | const absolutePath = splitAbsolutePath(normalized); | ||
| 167 | if (absolutePath) { | ||
| 168 | return absolutePath.segments.findLast(Boolean) ?? normalized; | ||
| 169 | } | ||
| 170 | const segments = normalized.split(/[/\\]/); | ||
| 171 | return segments.findLast(Boolean) ?? normalized; | ||
| 172 | } | ||
| 173 | |||
| 174 | export function appendBrowsePathSegment(currentPath: string, segment: string): string { | ||
| 175 | const separator = preferredPathSeparator(currentPath); | ||
| 176 | return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`; | ||
| 177 | } | ||
| 178 | |||
| 179 | export function getBrowseLeafPathSegment(currentPath: string): string { | ||
| 180 | const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); | ||
| 181 | return currentPath.slice(lastSeparatorIndex + 1); | ||
| 182 | } | ||
| 183 | |||
| 184 | export function getBrowseDirectoryPath(currentPath: string): string { | ||
| 185 | if (hasTrailingPathSeparator(currentPath)) { | ||
| 186 | return currentPath; | ||
| 187 | } | ||
| 188 | const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); | ||
| 189 | return lastSeparatorIndex < 0 ? currentPath : currentPath.slice(0, lastSeparatorIndex + 1); | ||
| 190 | } | ||
| 191 | |||
| 192 | export function ensureBrowseDirectoryPath(currentPath: string): string { | ||
| 193 | const trimmed = currentPath.trim(); | ||
| 194 | if (trimmed.length === 0 || hasTrailingPathSeparator(trimmed)) { | ||
| 195 | return trimmed; | ||
| 196 | } | ||
| 197 | return `${trimmed}${preferredPathSeparator(trimmed)}`; | ||
| 198 | } | ||
| 199 | |||
| 200 | export function getBrowseParentPath(currentPath: string): string | null { | ||
| 201 | const trimmed = trimTrailingPathSeparators(currentPath); | ||
| 202 | const absolutePath = splitAbsolutePath(trimmed); | ||
| 203 | if (absolutePath) { | ||
| 204 | if (absolutePath.segments.length === 0) return null; | ||
| 205 | if (absolutePath.segments.length === 1) return absolutePath.root; | ||
| 206 | const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator); | ||
| 207 | return `${absolutePath.root}${parentSegments}${absolutePath.separator}`; | ||
| 208 | } | ||
| 209 | |||
| 210 | const separator = preferredPathSeparator(currentPath); | ||
| 211 | const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed); | ||
| 212 | if (lastSeparatorIndex < 0) return null; | ||
| 213 | if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { | ||
| 214 | return `${trimmed.slice(0, 2)}${separator}`; | ||
| 215 | } | ||
| 216 | return trimmed.slice(0, lastSeparatorIndex + 1); | ||
| 217 | } | ||
| 218 | |||
| 219 | export function canNavigateUp(currentPath: string): boolean { | ||
| 220 | return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; | ||
| 221 | } | ||
packages/client-runtime/src/reconnectBackoff.test.ts created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | DEFAULT_RECONNECT_BACKOFF, | ||
| 5 | getReconnectDelayMs, | ||
| 6 | type ReconnectBackoffConfig, | ||
| 7 | } from "./reconnectBackoff.ts"; | ||
| 8 | |||
| 9 | describe("getReconnectDelayMs", () => { | ||
| 10 | it("returns exponential delays with default config", () => { | ||
| 11 | expect(getReconnectDelayMs(0)).toBe(1_000); | ||
| 12 | expect(getReconnectDelayMs(1)).toBe(2_000); | ||
| 13 | expect(getReconnectDelayMs(2)).toBe(4_000); | ||
| 14 | expect(getReconnectDelayMs(3)).toBe(8_000); | ||
| 15 | expect(getReconnectDelayMs(4)).toBe(16_000); | ||
| 16 | expect(getReconnectDelayMs(5)).toBe(32_000); | ||
| 17 | expect(getReconnectDelayMs(6)).toBe(64_000); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("returns null when retry index exceeds maxRetries", () => { | ||
| 21 | expect(getReconnectDelayMs(7)).toBeNull(); | ||
| 22 | expect(getReconnectDelayMs(100)).toBeNull(); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("returns null for negative indices", () => { | ||
| 26 | expect(getReconnectDelayMs(-1)).toBeNull(); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("returns null for non-integer indices", () => { | ||
| 30 | expect(getReconnectDelayMs(1.5)).toBeNull(); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("caps delay at maxDelayMs", () => { | ||
| 34 | const config: ReconnectBackoffConfig = { | ||
| 35 | initialDelayMs: 10_000, | ||
| 36 | backoffFactor: 10, | ||
| 37 | maxDelayMs: 30_000, | ||
| 38 | maxRetries: 5, | ||
| 39 | }; | ||
| 40 | |||
| 41 | expect(getReconnectDelayMs(0, config)).toBe(10_000); | ||
| 42 | expect(getReconnectDelayMs(1, config)).toBe(30_000); // 100_000 capped to 30_000 | ||
| 43 | expect(getReconnectDelayMs(2, config)).toBe(30_000); // 1_000_000 capped to 30_000 | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("supports unlimited retries when maxRetries is null", () => { | ||
| 47 | const config: ReconnectBackoffConfig = { | ||
| 48 | ...DEFAULT_RECONNECT_BACKOFF, | ||
| 49 | maxRetries: null, | ||
| 50 | }; | ||
| 51 | |||
| 52 | expect(getReconnectDelayMs(0, config)).toBe(1_000); | ||
| 53 | expect(getReconnectDelayMs(50, config)).toBe(64_000); // capped at maxDelayMs | ||
| 54 | expect(getReconnectDelayMs(100, config)).toBe(64_000); | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | |||
| 58 | describe("DEFAULT_RECONNECT_BACKOFF", () => { | ||
| 59 | it("has sensible defaults", () => { | ||
| 60 | expect(DEFAULT_RECONNECT_BACKOFF.initialDelayMs).toBe(1_000); | ||
| 61 | expect(DEFAULT_RECONNECT_BACKOFF.backoffFactor).toBe(2); | ||
| 62 | expect(DEFAULT_RECONNECT_BACKOFF.maxDelayMs).toBe(64_000); | ||
| 63 | expect(DEFAULT_RECONNECT_BACKOFF.maxRetries).toBe(7); | ||
| 64 | }); | ||
| 65 | }); | ||
packages/client-runtime/src/reconnectBackoff.ts created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | /** | ||
| 2 | * Configuration for exponential reconnect backoff. | ||
| 3 | */ | ||
| 4 | export interface ReconnectBackoffConfig { | ||
| 5 | /** Base delay in milliseconds before the first retry. */ | ||
| 6 | readonly initialDelayMs: number; | ||
| 7 | /** Multiplier applied per retry (exponential factor). */ | ||
| 8 | readonly backoffFactor: number; | ||
| 9 | /** Hard upper bound on delay in milliseconds. */ | ||
| 10 | readonly maxDelayMs: number; | ||
| 11 | /** Maximum number of retries (0-based). `null` means unlimited. */ | ||
| 12 | readonly maxRetries: number | null; | ||
| 13 | } | ||
| 14 | |||
| 15 | /** | ||
| 16 | * Sensible defaults for WebSocket reconnect backoff. | ||
| 17 | * | ||
| 18 | * - 1 s initial delay, doubling each retry, capped at 64 s, up to 7 retries. | ||
| 19 | */ | ||
| 20 | export const DEFAULT_RECONNECT_BACKOFF: ReconnectBackoffConfig = { | ||
| 21 | initialDelayMs: 1_000, | ||
| 22 | backoffFactor: 2, | ||
| 23 | maxDelayMs: 64_000, | ||
| 24 | maxRetries: 7, | ||
| 25 | }; | ||
| 26 | |||
| 27 | /** | ||
| 28 | * Calculate the reconnect delay for a given retry index using exponential | ||
| 29 | * backoff. Returns `null` when `retryIndex` exceeds the configured maximum. | ||
| 30 | */ | ||
| 31 | export function getReconnectDelayMs( | ||
| 32 | retryIndex: number, | ||
| 33 | config: ReconnectBackoffConfig = DEFAULT_RECONNECT_BACKOFF, | ||
| 34 | ): number | null { | ||
| 35 | if (!Number.isInteger(retryIndex) || retryIndex < 0) { | ||
| 36 | return null; | ||
| 37 | } | ||
| 38 | |||
| 39 | if (config.maxRetries !== null && retryIndex >= config.maxRetries) { | ||
| 40 | return null; | ||
| 41 | } | ||
| 42 | |||
| 43 | return Math.min( | ||
| 44 | Math.round(config.initialDelayMs * config.backoffFactor ** retryIndex), | ||
| 45 | config.maxDelayMs, | ||
| 46 | ); | ||
| 47 | } | ||
packages/client-runtime/src/remote.test.ts created+251| ... | @@ -0,0 +1,251 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as TestClock from "effect/testing/TestClock"; | ||
| 6 | import { HttpClient } from "effect/unstable/http"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | bootstrapRemoteBearerSession, | ||
| 10 | fetchRemoteEnvironmentDescriptor, | ||
| 11 | fetchRemoteSessionState, | ||
| 12 | issueRemoteWebSocketToken, | ||
| 13 | remoteHttpClientLayer, | ||
| 14 | RemoteEnvironmentAuthTimeoutError, | ||
| 15 | resolveRemoteWebSocketConnectionUrl, | ||
| 16 | } from "./remote.ts"; | ||
| 17 | |||
| 18 | type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit]; | ||
| 19 | |||
| 20 | const recordedFetch = (...responses: ReadonlyArray<Response>) => { | ||
| 21 | const calls: Array<FetchCall> = []; | ||
| 22 | let responseIndex = 0; | ||
| 23 | const fetchFn = ((input, init) => { | ||
| 24 | calls.push([input, init ?? {}]); | ||
| 25 | const response = responses[responseIndex++]; | ||
| 26 | if (!response) { | ||
| 27 | return Promise.reject(new Error("Unexpected fetch call")); | ||
| 28 | } | ||
| 29 | return Promise.resolve(response); | ||
| 30 | }) satisfies typeof fetch; | ||
| 31 | |||
| 32 | return { fetchFn, calls }; | ||
| 33 | }; | ||
| 34 | |||
| 35 | const hangingFetch = () => { | ||
| 36 | const calls: Array<FetchCall> = []; | ||
| 37 | const fetchFn = ((input, init) => { | ||
| 38 | calls.push([input, init ?? {}]); | ||
| 39 | return new Promise<Response>(() => undefined); | ||
| 40 | }) satisfies typeof fetch; | ||
| 41 | |||
| 42 | return { fetchFn, calls }; | ||
| 43 | }; | ||
| 44 | |||
| 45 | const provideRemoteHttp = | ||
| 46 | (fetchFn: typeof fetch) => | ||
| 47 | <A, E, R>(effect: Effect.Effect<A, E, R | HttpClient.HttpClient>) => | ||
| 48 | effect.pipe(Effect.provide(remoteHttpClientLayer(fetchFn))); | ||
| 49 | |||
| 50 | const expectFetchCall = ( | ||
| 51 | calls: ReadonlyArray<FetchCall>, | ||
| 52 | index: number, | ||
| 53 | expected: { | ||
| 54 | readonly url: string; | ||
| 55 | readonly method: string; | ||
| 56 | readonly headers?: Record<string, string>; | ||
| 57 | readonly body?: string; | ||
| 58 | }, | ||
| 59 | ): void => { | ||
| 60 | const call = calls[index - 1]; | ||
| 61 | expect(call).toBeDefined(); | ||
| 62 | if (!call) { | ||
| 63 | return; | ||
| 64 | } | ||
| 65 | |||
| 66 | const [url, init] = call; | ||
| 67 | expect(String(url)).toBe(expected.url); | ||
| 68 | expect(init).toEqual( | ||
| 69 | expect.objectContaining({ | ||
| 70 | method: expected.method, | ||
| 71 | }), | ||
| 72 | ); | ||
| 73 | expect(init.headers).toEqual(expect.objectContaining(expected.headers ?? {})); | ||
| 74 | |||
| 75 | if ("body" in expected) { | ||
| 76 | const body = init.body; | ||
| 77 | if (typeof body === "string") { | ||
| 78 | expect(body).toBe(expected.body); | ||
| 79 | } else if (body instanceof Uint8Array) { | ||
| 80 | expect(new TextDecoder().decode(body)).toBe(expected.body); | ||
| 81 | } else { | ||
| 82 | throw new Error("Expected fetch request body"); | ||
| 83 | } | ||
| 84 | } | ||
| 85 | }; | ||
| 86 | |||
| 87 | describe("remote", () => { | ||
| 88 | it.effect("bootstraps bearer auth against a remote backend", () => | ||
| 89 | Effect.gen(function* () { | ||
| 90 | const fetch = recordedFetch( | ||
| 91 | Response.json( | ||
| 92 | { | ||
| 93 | authenticated: true, | ||
| 94 | role: "client", | ||
| 95 | sessionMethod: "bearer-session-token", | ||
| 96 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 97 | sessionToken: "bearer-token", | ||
| 98 | }, | ||
| 99 | { status: 200 }, | ||
| 100 | ), | ||
| 101 | ); | ||
| 102 | |||
| 103 | const result = yield* bootstrapRemoteBearerSession({ | ||
| 104 | httpBaseUrl: "https://remote.example.com/", | ||
| 105 | credential: "pairing-token", | ||
| 106 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 107 | |||
| 108 | expect(result).toMatchObject({ | ||
| 109 | sessionMethod: "bearer-session-token", | ||
| 110 | sessionToken: "bearer-token", | ||
| 111 | }); | ||
| 112 | expectFetchCall(fetch.calls, 1, { | ||
| 113 | url: "https://remote.example.com/api/auth/bootstrap/bearer", | ||
| 114 | method: "POST", | ||
| 115 | headers: { | ||
| 116 | "content-type": "application/json", | ||
| 117 | }, | ||
| 118 | body: `{"credential":"pairing-token"}`, | ||
| 119 | }); | ||
| 120 | }), | ||
| 121 | ); | ||
| 122 | |||
| 123 | it.effect("loads remote session state and websocket tokens over bearer auth", () => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const fetch = recordedFetch( | ||
| 126 | Response.json( | ||
| 127 | { | ||
| 128 | environmentId: "environment-remote", | ||
| 129 | label: "Remote environment", | ||
| 130 | platform: { | ||
| 131 | os: "linux", | ||
| 132 | arch: "x64", | ||
| 133 | }, | ||
| 134 | serverVersion: "0.0.0-test", | ||
| 135 | capabilities: { | ||
| 136 | repositoryIdentity: true, | ||
| 137 | }, | ||
| 138 | }, | ||
| 139 | { status: 200 }, | ||
| 140 | ), | ||
| 141 | Response.json( | ||
| 142 | { | ||
| 143 | authenticated: true, | ||
| 144 | auth: { | ||
| 145 | policy: "remote-reachable", | ||
| 146 | bootstrapMethods: ["one-time-token"], | ||
| 147 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | ||
| 148 | sessionCookieName: "t3_session", | ||
| 149 | }, | ||
| 150 | role: "client", | ||
| 151 | sessionMethod: "bearer-session-token", | ||
| 152 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 153 | }, | ||
| 154 | { status: 200 }, | ||
| 155 | ), | ||
| 156 | Response.json( | ||
| 157 | { | ||
| 158 | token: "ws-token", | ||
| 159 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 160 | }, | ||
| 161 | { status: 200 }, | ||
| 162 | ), | ||
| 163 | ); | ||
| 164 | |||
| 165 | const environment = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 166 | httpBaseUrl: "https://remote.example.com/", | ||
| 167 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 168 | expect(environment).toMatchObject({ | ||
| 169 | environmentId: "environment-remote", | ||
| 170 | label: "Remote environment", | ||
| 171 | }); | ||
| 172 | |||
| 173 | const session = yield* fetchRemoteSessionState({ | ||
| 174 | httpBaseUrl: "https://remote.example.com/", | ||
| 175 | bearerToken: "bearer-token", | ||
| 176 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 177 | expect(session).toMatchObject({ | ||
| 178 | authenticated: true, | ||
| 179 | role: "client", | ||
| 180 | }); | ||
| 181 | |||
| 182 | const token = yield* issueRemoteWebSocketToken({ | ||
| 183 | httpBaseUrl: "https://remote.example.com/", | ||
| 184 | bearerToken: "bearer-token", | ||
| 185 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 186 | expect(token).toMatchObject({ | ||
| 187 | token: "ws-token", | ||
| 188 | }); | ||
| 189 | |||
| 190 | expectFetchCall(fetch.calls, 1, { | ||
| 191 | url: "https://remote.example.com/.well-known/t3/environment", | ||
| 192 | method: "GET", | ||
| 193 | }); | ||
| 194 | expectFetchCall(fetch.calls, 2, { | ||
| 195 | url: "https://remote.example.com/api/auth/session", | ||
| 196 | method: "GET", | ||
| 197 | headers: { | ||
| 198 | authorization: "Bearer bearer-token", | ||
| 199 | }, | ||
| 200 | }); | ||
| 201 | expectFetchCall(fetch.calls, 3, { | ||
| 202 | url: "https://remote.example.com/api/auth/ws-token", | ||
| 203 | method: "POST", | ||
| 204 | headers: { | ||
| 205 | authorization: "Bearer bearer-token", | ||
| 206 | }, | ||
| 207 | }); | ||
| 208 | }), | ||
| 209 | ); | ||
| 210 | |||
| 211 | it.effect("fails hung fetch requests on the configured timeout", () => | ||
| 212 | Effect.gen(function* () { | ||
| 213 | const fetch = hangingFetch(); | ||
| 214 | |||
| 215 | const errorFiber = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 216 | httpBaseUrl: "http://remote.example.com/", | ||
| 217 | timeoutMs: 25, | ||
| 218 | }).pipe(provideRemoteHttp(fetch.fetchFn), Effect.flip, Effect.forkScoped); | ||
| 219 | yield* Effect.yieldNow; | ||
| 220 | yield* TestClock.adjust(Duration.millis(25)); | ||
| 221 | const error = yield* Fiber.join(errorFiber); | ||
| 222 | |||
| 223 | expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError); | ||
| 224 | expect(error.message).toBe( | ||
| 225 | "Remote auth endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.", | ||
| 226 | ); | ||
| 227 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 228 | ); | ||
| 229 | |||
| 230 | it.effect("mints a websocket url that targets the rpc route with a short-lived ws token", () => | ||
| 231 | Effect.gen(function* () { | ||
| 232 | const fetch = recordedFetch( | ||
| 233 | Response.json( | ||
| 234 | { | ||
| 235 | token: "ws-token", | ||
| 236 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 237 | }, | ||
| 238 | { status: 200 }, | ||
| 239 | ), | ||
| 240 | ); | ||
| 241 | |||
| 242 | const url = yield* resolveRemoteWebSocketConnectionUrl({ | ||
| 243 | wsBaseUrl: "wss://remote.example.com/", | ||
| 244 | httpBaseUrl: "https://remote.example.com/", | ||
| 245 | bearerToken: "bearer-token", | ||
| 246 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 247 | |||
| 248 | expect(url).toBe("wss://remote.example.com/ws?wsToken=ws-token"); | ||
| 249 | }), | ||
| 250 | ); | ||
| 251 | }); | ||
packages/client-runtime/src/remote.ts created+269| ... | @@ -0,0 +1,269 @@ | ||
| 1 | import type { | ||
| 2 | AuthBearerBootstrapResult, | ||
| 3 | AuthSessionState, | ||
| 4 | AuthWebSocketTokenResult, | ||
| 5 | ExecutionEnvironmentDescriptor, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 8 | import * as Data from "effect/Data"; | ||
| 9 | import * as Duration from "effect/Duration"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Result from "effect/Result"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import { identity } from "effect/Function"; | ||
| 16 | import { | ||
| 17 | FetchHttpClient, | ||
| 18 | HttpClient, | ||
| 19 | HttpClientRequest, | ||
| 20 | HttpClientResponse, | ||
| 21 | } from "effect/unstable/http"; | ||
| 22 | |||
| 23 | const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; | ||
| 24 | const RemoteAuthErrorBody = Schema.Struct({ | ||
| 25 | error: Schema.optional(Schema.String), | ||
| 26 | }); | ||
| 27 | const decodeRemoteAuthErrorBody = decodeJsonResult(RemoteAuthErrorBody); | ||
| 28 | |||
| 29 | const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { | ||
| 30 | const url = new URL(httpBaseUrl); | ||
| 31 | url.pathname = pathname; | ||
| 32 | url.search = ""; | ||
| 33 | url.hash = ""; | ||
| 34 | return url.toString(); | ||
| 35 | }; | ||
| 36 | |||
| 37 | export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( | ||
| 38 | "RemoteEnvironmentAuthFetchError", | ||
| 39 | )<{ | ||
| 40 | readonly message: string; | ||
| 41 | readonly cause: unknown; | ||
| 42 | }> {} | ||
| 43 | |||
| 44 | export class RemoteEnvironmentAuthResponseReadError extends Data.TaggedError( | ||
| 45 | "RemoteEnvironmentAuthResponseReadError", | ||
| 46 | )<{ | ||
| 47 | readonly message: string; | ||
| 48 | readonly cause: unknown; | ||
| 49 | }> {} | ||
| 50 | |||
| 51 | export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( | ||
| 52 | "RemoteEnvironmentAuthInvalidJsonError", | ||
| 53 | )<{ | ||
| 54 | readonly message: string; | ||
| 55 | readonly cause: unknown; | ||
| 56 | }> {} | ||
| 57 | |||
| 58 | export class RemoteEnvironmentAuthHttpError extends Data.TaggedError( | ||
| 59 | "RemoteEnvironmentAuthHttpError", | ||
| 60 | )<{ | ||
| 61 | readonly message: string; | ||
| 62 | readonly status: number; | ||
| 63 | }> { | ||
| 64 | constructor(message: string, status: number) { | ||
| 65 | super({ message, status }); | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( | ||
| 70 | "RemoteEnvironmentAuthTimeoutError", | ||
| 71 | )<{ | ||
| 72 | readonly message: string; | ||
| 73 | readonly requestUrl: string; | ||
| 74 | readonly timeoutMs: number; | ||
| 75 | }> { | ||
| 76 | constructor(requestUrl: string, timeoutMs: number) { | ||
| 77 | super({ | ||
| 78 | message: `Remote auth endpoint ${requestUrl} timed out after ${timeoutMs}ms.`, | ||
| 79 | requestUrl, | ||
| 80 | timeoutMs, | ||
| 81 | }); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | export type RemoteEnvironmentAuthError = | ||
| 86 | | RemoteEnvironmentAuthFetchError | ||
| 87 | | RemoteEnvironmentAuthResponseReadError | ||
| 88 | | RemoteEnvironmentAuthInvalidJsonError | ||
| 89 | | RemoteEnvironmentAuthHttpError | ||
| 90 | | RemoteEnvironmentAuthTimeoutError; | ||
| 91 | |||
| 92 | export const isRemoteEnvironmentAuthHttpError = ( | ||
| 93 | error: unknown, | ||
| 94 | ): error is RemoteEnvironmentAuthHttpError => error instanceof RemoteEnvironmentAuthHttpError; | ||
| 95 | |||
| 96 | const readRemoteAuthErrorMessage = ( | ||
| 97 | response: HttpClientResponse.HttpClientResponse, | ||
| 98 | fallbackMessage: string, | ||
| 99 | ): Effect.Effect<string, RemoteEnvironmentAuthResponseReadError> => | ||
| 100 | response.text.pipe( | ||
| 101 | Effect.mapError( | ||
| 102 | (cause) => | ||
| 103 | new RemoteEnvironmentAuthResponseReadError({ | ||
| 104 | message: "Remote auth endpoint returned an unreadable error response.", | ||
| 105 | cause, | ||
| 106 | }), | ||
| 107 | ), | ||
| 108 | Effect.map((text) => { | ||
| 109 | if (!text) { | ||
| 110 | return fallbackMessage; | ||
| 111 | } | ||
| 112 | |||
| 113 | const decoded = decodeRemoteAuthErrorBody(text); | ||
| 114 | if (Result.isSuccess(decoded) && decoded.success.error) { | ||
| 115 | return decoded.success.error; | ||
| 116 | } | ||
| 117 | |||
| 118 | return text; | ||
| 119 | }), | ||
| 120 | ); | ||
| 121 | |||
| 122 | const readRemoteJson = <T>( | ||
| 123 | response: HttpClientResponse.HttpClientResponse, | ||
| 124 | requestUrl: string, | ||
| 125 | ): Effect.Effect<T, RemoteEnvironmentAuthInvalidJsonError> => | ||
| 126 | response.json.pipe( | ||
| 127 | Effect.mapError( | ||
| 128 | (cause) => | ||
| 129 | new RemoteEnvironmentAuthInvalidJsonError({ | ||
| 130 | message: `Remote auth endpoint returned invalid JSON from ${requestUrl}.`, | ||
| 131 | cause, | ||
| 132 | }), | ||
| 133 | ), | ||
| 134 | Effect.map((value) => value as T), | ||
| 135 | ); | ||
| 136 | |||
| 137 | export const remoteHttpClientLayer = ( | ||
| 138 | fetchFn: typeof globalThis.fetch, | ||
| 139 | ): Layer.Layer<HttpClient.HttpClient> => | ||
| 140 | FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))); | ||
| 141 | |||
| 142 | const fetchRemoteJson = Effect.fn("clientRuntime.remote.fetchRemoteJson")(function* <T>(input: { | ||
| 143 | readonly httpBaseUrl: string; | ||
| 144 | readonly pathname: string; | ||
| 145 | readonly method?: "GET" | "POST"; | ||
| 146 | readonly bearerToken?: string; | ||
| 147 | readonly body?: unknown; | ||
| 148 | readonly timeoutMs?: number; | ||
| 149 | }) { | ||
| 150 | const requestUrl = remoteEndpointUrl(input.httpBaseUrl, input.pathname); | ||
| 151 | const method = input.method ?? "GET"; | ||
| 152 | const timeoutMs = input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS; | ||
| 153 | const request = HttpClientRequest.make(method)(requestUrl).pipe( | ||
| 154 | input.bearerToken ? HttpClientRequest.bearerToken(input.bearerToken) : identity, | ||
| 155 | input.body !== undefined ? HttpClientRequest.bodyJsonUnsafe(input.body) : identity, | ||
| 156 | ); | ||
| 157 | |||
| 158 | const client = yield* HttpClient.HttpClient; | ||
| 159 | const response = yield* client.execute(request).pipe( | ||
| 160 | Effect.mapError( | ||
| 161 | (cause) => | ||
| 162 | new RemoteEnvironmentAuthFetchError({ | ||
| 163 | message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`, | ||
| 164 | cause, | ||
| 165 | }), | ||
| 166 | ), | ||
| 167 | Effect.timeoutOption(Duration.millis(timeoutMs)), | ||
| 168 | Effect.flatMap( | ||
| 169 | Option.match({ | ||
| 170 | onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)), | ||
| 171 | onSome: Effect.succeed, | ||
| 172 | }), | ||
| 173 | ), | ||
| 174 | ); | ||
| 175 | |||
| 176 | if (response.status < 200 || response.status >= 300) { | ||
| 177 | return yield* readRemoteAuthErrorMessage( | ||
| 178 | response, | ||
| 179 | `Remote auth request failed (${response.status}).`, | ||
| 180 | ).pipe( | ||
| 181 | Effect.flatMap((message) => | ||
| 182 | Effect.fail(new RemoteEnvironmentAuthHttpError(message, response.status)), | ||
| 183 | ), | ||
| 184 | ); | ||
| 185 | } | ||
| 186 | |||
| 187 | return yield* readRemoteJson<T>(response, requestUrl); | ||
| 188 | }); | ||
| 189 | |||
| 190 | export const bootstrapRemoteBearerSession = Effect.fn( | ||
| 191 | "clientRuntime.remote.bootstrapRemoteBearerSession", | ||
| 192 | )(function* (input: { | ||
| 193 | readonly httpBaseUrl: string; | ||
| 194 | readonly credential: string; | ||
| 195 | readonly timeoutMs?: number; | ||
| 196 | }) { | ||
| 197 | return yield* fetchRemoteJson<AuthBearerBootstrapResult>({ | ||
| 198 | httpBaseUrl: input.httpBaseUrl, | ||
| 199 | pathname: "/api/auth/bootstrap/bearer", | ||
| 200 | method: "POST", | ||
| 201 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | ||
| 202 | body: { | ||
| 203 | credential: input.credential, | ||
| 204 | }, | ||
| 205 | }); | ||
| 206 | }); | ||
| 207 | |||
| 208 | export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")( | ||
| 209 | function* (input: { | ||
| 210 | readonly httpBaseUrl: string; | ||
| 211 | readonly bearerToken: string; | ||
| 212 | readonly timeoutMs?: number; | ||
| 213 | }) { | ||
| 214 | return yield* fetchRemoteJson<AuthSessionState>({ | ||
| 215 | httpBaseUrl: input.httpBaseUrl, | ||
| 216 | pathname: "/api/auth/session", | ||
| 217 | bearerToken: input.bearerToken, | ||
| 218 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | ||
| 219 | }); | ||
| 220 | }, | ||
| 221 | ); | ||
| 222 | |||
| 223 | export const fetchRemoteEnvironmentDescriptor = Effect.fn( | ||
| 224 | "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", | ||
| 225 | )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { | ||
| 226 | return yield* fetchRemoteJson<ExecutionEnvironmentDescriptor>({ | ||
| 227 | httpBaseUrl: input.httpBaseUrl, | ||
| 228 | pathname: "/.well-known/t3/environment", | ||
| 229 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | ||
| 230 | }); | ||
| 231 | }); | ||
| 232 | |||
| 233 | export const issueRemoteWebSocketToken = Effect.fn( | ||
| 234 | "clientRuntime.remote.issueRemoteWebSocketToken", | ||
| 235 | )(function* (input: { | ||
| 236 | readonly httpBaseUrl: string; | ||
| 237 | readonly bearerToken: string; | ||
| 238 | readonly timeoutMs?: number; | ||
| 239 | }) { | ||
| 240 | return yield* fetchRemoteJson<AuthWebSocketTokenResult>({ | ||
| 241 | httpBaseUrl: input.httpBaseUrl, | ||
| 242 | pathname: "/api/auth/ws-token", | ||
| 243 | method: "POST", | ||
| 244 | bearerToken: input.bearerToken, | ||
| 245 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | ||
| 246 | }); | ||
| 247 | }); | ||
| 248 | |||
| 249 | export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ||
| 250 | "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl", | ||
| 251 | )(function* (input: { | ||
| 252 | readonly wsBaseUrl: string; | ||
| 253 | readonly httpBaseUrl: string; | ||
| 254 | readonly bearerToken: string; | ||
| 255 | readonly timeoutMs?: number; | ||
| 256 | }) { | ||
| 257 | const issued = yield* issueRemoteWebSocketToken({ | ||
| 258 | httpBaseUrl: input.httpBaseUrl, | ||
| 259 | bearerToken: input.bearerToken, | ||
| 260 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | ||
| 261 | }); | ||
| 262 | |||
| 263 | const url = new URL(input.wsBaseUrl); | ||
| 264 | if (url.pathname === "" || url.pathname === "/") { | ||
| 265 | url.pathname = "/ws"; | ||
| 266 | } | ||
| 267 | url.searchParams.set("wsToken", issued.token); | ||
| 268 | return url.toString(); | ||
| 269 | }); | ||
packages/client-runtime/src/scoped.ts+15-10| ... | @@ -1,19 +1,24 @@ | ... | @@ -1,19 +1,24 @@ |
| 1 | import type { | 1 | import { |
| 2 | EnvironmentId, | 2 | EnvironmentId, |
| 3 | ProjectId, | 3 | ProjectId, |
| 4 | ScopedProjectRef, | ||
| 5 | ScopedThreadRef, | ||
| 6 | ThreadId, | 4 | ThreadId, |
| 5 | type EnvironmentId as EnvironmentIdType, | ||
| 6 | type ProjectId as ProjectIdType, | ||
| 7 | type ScopedProjectRef, | ||
| 8 | type ScopedThreadRef, | ||
| 7 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 8 | 10 | ||
| 9 | export function scopeProjectRef( | 11 | export function scopeProjectRef( |
| 10 | environmentId: EnvironmentId, | 12 | environmentId: EnvironmentIdType, |
| 11 | projectId: ProjectId, | 13 | projectId: ProjectIdType, |
| 12 | ): ScopedProjectRef { | 14 | ): ScopedProjectRef { |
| 13 | return { environmentId, projectId }; | 15 | return { environmentId, projectId }; |
| 14 | } | 16 | } |
| 15 | 17 | ||
| 16 | export function scopeThreadRef(environmentId: EnvironmentId, threadId: ThreadId): ScopedThreadRef { | 18 | export function scopeThreadRef( |
| 19 | environmentId: EnvironmentIdType, | ||
| 20 | threadId: ThreadId, | ||
| 21 | ): ScopedThreadRef { | ||
| 17 | return { environmentId, threadId }; | 22 | return { environmentId, threadId }; |
| 18 | } | 23 | } |
| 19 | 24 | ||
| ... | @@ -30,13 +35,13 @@ export function scopedThreadKey(ref: ScopedThreadRef): string { | ... | @@ -30,13 +35,13 @@ export function scopedThreadKey(ref: ScopedThreadRef): string { |
| 30 | return scopedRefKey(ref); | 35 | return scopedRefKey(ref); |
| 31 | } | 36 | } |
| 32 | 37 | ||
| 33 | function parseScopedKey(key: string): { environmentId: EnvironmentId; localId: string } | null { | 38 | function parseScopedKey(key: string): { environmentId: EnvironmentIdType; localId: string } | null { |
| 34 | const separatorIndex = key.indexOf(":"); | 39 | const separatorIndex = key.indexOf(":"); |
| 35 | if (separatorIndex <= 0 || separatorIndex >= key.length - 1) { | 40 | if (separatorIndex <= 0 || separatorIndex >= key.length - 1) { |
| 36 | return null; | 41 | return null; |
| 37 | } | 42 | } |
| 38 | return { | 43 | return { |
| 39 | environmentId: key.slice(0, separatorIndex) as EnvironmentId, | 44 | environmentId: EnvironmentId.make(key.slice(0, separatorIndex)), |
| 40 | localId: key.slice(separatorIndex + 1), | 45 | localId: key.slice(separatorIndex + 1), |
| 41 | }; | 46 | }; |
| 42 | } | 47 | } |
| ... | @@ -48,7 +53,7 @@ export function parseScopedProjectKey(key: string): ScopedProjectRef | null { | ... | @@ -48,7 +53,7 @@ export function parseScopedProjectKey(key: string): ScopedProjectRef | null { |
| 48 | } | 53 | } |
| 49 | return { | 54 | return { |
| 50 | environmentId: parsed.environmentId, | 55 | environmentId: parsed.environmentId, |
| 51 | projectId: parsed.localId as ProjectId, | 56 | projectId: ProjectId.make(parsed.localId), |
| 52 | }; | 57 | }; |
| 53 | } | 58 | } |
| 54 | 59 | ||
| ... | @@ -59,6 +64,6 @@ export function parseScopedThreadKey(key: string): ScopedThreadRef | null { | ... | @@ -59,6 +64,6 @@ export function parseScopedThreadKey(key: string): ScopedThreadRef | null { |
| 59 | } | 64 | } |
| 60 | return { | 65 | return { |
| 61 | environmentId: parsed.environmentId, | 66 | environmentId: parsed.environmentId, |
| 62 | threadId: parsed.localId as ThreadId, | 67 | threadId: ThreadId.make(parsed.localId), |
| 63 | }; | 68 | }; |
| 64 | } | 69 | } |
packages/client-runtime/src/shellSnapshotReducer.test.ts created+163| ... | @@ -0,0 +1,163 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import { applyShellStreamEvent } from "./shellSnapshotReducer.ts"; | ||
| 7 | |||
| 8 | const baseSnapshot: OrchestrationShellSnapshot = { | ||
| 9 | snapshotSequence: 0, | ||
| 10 | projects: [], | ||
| 11 | threads: [], | ||
| 12 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 13 | }; | ||
| 14 | |||
| 15 | const stubProject = { | ||
| 16 | id: ProjectId.make("project-1"), | ||
| 17 | title: "Test Project", | ||
| 18 | workspaceRoot: "/workspace/test", | ||
| 19 | repositoryIdentity: null, | ||
| 20 | defaultModelSelection: null, | ||
| 21 | scripts: [], | ||
| 22 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 23 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 24 | } as const; | ||
| 25 | |||
| 26 | const stubThread = { | ||
| 27 | id: ThreadId.make("thread-1"), | ||
| 28 | projectId: ProjectId.make("project-1"), | ||
| 29 | title: "Test Thread", | ||
| 30 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 31 | runtimeMode: "full-access" as const, | ||
| 32 | interactionMode: "default" as const, | ||
| 33 | branch: null, | ||
| 34 | worktreePath: null, | ||
| 35 | latestTurn: null, | ||
| 36 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 37 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 38 | archivedAt: null, | ||
| 39 | latestUserMessageAt: null, | ||
| 40 | hasPendingApprovals: false, | ||
| 41 | hasPendingUserInput: false, | ||
| 42 | hasActionableProposedPlan: false, | ||
| 43 | session: null, | ||
| 44 | } as const; | ||
| 45 | |||
| 46 | describe("applyShellStreamEvent", () => { | ||
| 47 | describe("project-upserted", () => { | ||
| 48 | it("adds a new project", () => { | ||
| 49 | const event: OrchestrationShellStreamEvent = { | ||
| 50 | kind: "project-upserted", | ||
| 51 | sequence: 1, | ||
| 52 | project: stubProject, | ||
| 53 | }; | ||
| 54 | |||
| 55 | const next = applyShellStreamEvent(baseSnapshot, event); | ||
| 56 | |||
| 57 | expect(next.projects).toHaveLength(1); | ||
| 58 | expect(next.projects[0]?.id).toBe("project-1"); | ||
| 59 | expect(next.snapshotSequence).toBe(1); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("updates an existing project", () => { | ||
| 63 | const snapshotWithProject: OrchestrationShellSnapshot = { | ||
| 64 | ...baseSnapshot, | ||
| 65 | projects: [stubProject], | ||
| 66 | }; | ||
| 67 | |||
| 68 | const updatedProject = { ...stubProject, title: "Updated Title" }; | ||
| 69 | const event: OrchestrationShellStreamEvent = { | ||
| 70 | kind: "project-upserted", | ||
| 71 | sequence: 2, | ||
| 72 | project: updatedProject, | ||
| 73 | }; | ||
| 74 | |||
| 75 | const next = applyShellStreamEvent(snapshotWithProject, event); | ||
| 76 | |||
| 77 | expect(next.projects).toHaveLength(1); | ||
| 78 | expect(next.projects[0]?.title).toBe("Updated Title"); | ||
| 79 | expect(next.snapshotSequence).toBe(2); | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | describe("project-removed", () => { | ||
| 84 | it("removes a project by id", () => { | ||
| 85 | const snapshotWithProject: OrchestrationShellSnapshot = { | ||
| 86 | ...baseSnapshot, | ||
| 87 | projects: [stubProject], | ||
| 88 | }; | ||
| 89 | |||
| 90 | const event: OrchestrationShellStreamEvent = { | ||
| 91 | kind: "project-removed", | ||
| 92 | sequence: 3, | ||
| 93 | projectId: ProjectId.make("project-1"), | ||
| 94 | }; | ||
| 95 | |||
| 96 | const next = applyShellStreamEvent(snapshotWithProject, event); | ||
| 97 | |||
| 98 | expect(next.projects).toHaveLength(0); | ||
| 99 | expect(next.snapshotSequence).toBe(3); | ||
| 100 | }); | ||
| 101 | }); | ||
| 102 | |||
| 103 | describe("thread-upserted", () => { | ||
| 104 | it("adds a new thread", () => { | ||
| 105 | const event: OrchestrationShellStreamEvent = { | ||
| 106 | kind: "thread-upserted", | ||
| 107 | sequence: 4, | ||
| 108 | thread: stubThread, | ||
| 109 | }; | ||
| 110 | |||
| 111 | const next = applyShellStreamEvent(baseSnapshot, event); | ||
| 112 | |||
| 113 | expect(next.threads).toHaveLength(1); | ||
| 114 | expect(next.threads[0]?.id).toBe("thread-1"); | ||
| 115 | expect(next.snapshotSequence).toBe(4); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it("updates an existing thread", () => { | ||
| 119 | const snapshotWithThread: OrchestrationShellSnapshot = { | ||
| 120 | ...baseSnapshot, | ||
| 121 | threads: [stubThread], | ||
| 122 | }; | ||
| 123 | |||
| 124 | const updatedThread = { ...stubThread, title: "Updated Thread" }; | ||
| 125 | const event: OrchestrationShellStreamEvent = { | ||
| 126 | kind: "thread-upserted", | ||
| 127 | sequence: 5, | ||
| 128 | thread: updatedThread, | ||
| 129 | }; | ||
| 130 | |||
| 131 | const next = applyShellStreamEvent(snapshotWithThread, event); | ||
| 132 | |||
| 133 | expect(next.threads).toHaveLength(1); | ||
| 134 | expect(next.threads[0]?.title).toBe("Updated Thread"); | ||
| 135 | }); | ||
| 136 | }); | ||
| 137 | |||
| 138 | describe("thread-removed", () => { | ||
| 139 | it("removes a thread by id", () => { | ||
| 140 | const snapshotWithThread: OrchestrationShellSnapshot = { | ||
| 141 | ...baseSnapshot, | ||
| 142 | threads: [stubThread], | ||
| 143 | }; | ||
| 144 | |||
| 145 | const event: OrchestrationShellStreamEvent = { | ||
| 146 | kind: "thread-removed", | ||
| 147 | sequence: 6, | ||
| 148 | threadId: ThreadId.make("thread-1"), | ||
| 149 | }; | ||
| 150 | |||
| 151 | const next = applyShellStreamEvent(snapshotWithThread, event); | ||
| 152 | |||
| 153 | expect(next.threads).toHaveLength(0); | ||
| 154 | expect(next.snapshotSequence).toBe(6); | ||
| 155 | }); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it("returns original snapshot for unrecognized event kinds", () => { | ||
| 159 | const unknownEvent = { kind: "unknown-future-event", sequence: 99 } as any; | ||
| 160 | const next = applyShellStreamEvent(baseSnapshot, unknownEvent); | ||
| 161 | expect(next).toBe(baseSnapshot); | ||
| 162 | }); | ||
| 163 | }); | ||
packages/client-runtime/src/shellSnapshotReducer.ts created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | import * as Arr from "effect/Array"; | ||
| 2 | import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | /** | ||
| 5 | * Apply a single shell stream event to an existing snapshot, returning a new | ||
| 6 | * snapshot with the event's changes applied. This is a pure reducer that both | ||
| 7 | * web and mobile can use to keep their local shell snapshot in sync. | ||
| 8 | * | ||
| 9 | * Returns the original snapshot reference unchanged if the event is not | ||
| 10 | * recognized (forward-compatible). | ||
| 11 | */ | ||
| 12 | export function applyShellStreamEvent( | ||
| 13 | snapshot: OrchestrationShellSnapshot, | ||
| 14 | event: OrchestrationShellStreamEvent, | ||
| 15 | ): OrchestrationShellSnapshot { | ||
| 16 | switch (event.kind) { | ||
| 17 | case "project-upserted": { | ||
| 18 | const projects = snapshot.projects.some((p) => p.id === event.project.id) | ||
| 19 | ? Arr.map(snapshot.projects, (p) => (p.id === event.project.id ? event.project : p)) | ||
| 20 | : Arr.append(snapshot.projects, event.project); | ||
| 21 | return { ...snapshot, projects, snapshotSequence: event.sequence }; | ||
| 22 | } | ||
| 23 | case "project-removed": | ||
| 24 | return { | ||
| 25 | ...snapshot, | ||
| 26 | projects: Arr.filter(snapshot.projects, (p) => p.id !== event.projectId), | ||
| 27 | snapshotSequence: event.sequence, | ||
| 28 | }; | ||
| 29 | case "thread-upserted": { | ||
| 30 | const threads = snapshot.threads.some((t) => t.id === event.thread.id) | ||
| 31 | ? Arr.map(snapshot.threads, (t) => (t.id === event.thread.id ? event.thread : t)) | ||
| 32 | : Arr.append(snapshot.threads, event.thread); | ||
| 33 | return { ...snapshot, threads, snapshotSequence: event.sequence }; | ||
| 34 | } | ||
| 35 | case "thread-removed": | ||
| 36 | return { | ||
| 37 | ...snapshot, | ||
| 38 | threads: Arr.filter(snapshot.threads, (t) => t.id !== event.threadId), | ||
| 39 | snapshotSequence: event.sequence, | ||
| 40 | }; | ||
| 41 | default: | ||
| 42 | return snapshot; | ||
| 43 | } | ||
| 44 | } | ||
packages/client-runtime/src/shellSnapshotState.test.ts created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | EnvironmentId, | ||
| 6 | ProjectId, | ||
| 7 | ProviderInstanceId, | ||
| 8 | ThreadId, | ||
| 9 | type OrchestrationShellSnapshot, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | import { createShellSnapshotManager } from "./shellSnapshotState.ts"; | ||
| 13 | |||
| 14 | let atomRegistry = AtomRegistry.make(); | ||
| 15 | |||
| 16 | function resetAtomRegistry() { | ||
| 17 | atomRegistry.dispose(); | ||
| 18 | atomRegistry = AtomRegistry.make(); | ||
| 19 | } | ||
| 20 | |||
| 21 | const BASE_SNAPSHOT: OrchestrationShellSnapshot = { | ||
| 22 | snapshotSequence: 1, | ||
| 23 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 24 | projects: [ | ||
| 25 | { | ||
| 26 | id: ProjectId.make("project-1"), | ||
| 27 | title: "Project", | ||
| 28 | workspaceRoot: "/repo", | ||
| 29 | repositoryIdentity: null, | ||
| 30 | defaultModelSelection: null, | ||
| 31 | scripts: [], | ||
| 32 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 33 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 34 | }, | ||
| 35 | ], | ||
| 36 | threads: [ | ||
| 37 | { | ||
| 38 | id: ThreadId.make("thread-1"), | ||
| 39 | projectId: ProjectId.make("project-1"), | ||
| 40 | title: "Thread", | ||
| 41 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 42 | runtimeMode: "full-access", | ||
| 43 | interactionMode: "default", | ||
| 44 | branch: null, | ||
| 45 | worktreePath: null, | ||
| 46 | latestTurn: null, | ||
| 47 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 48 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 49 | archivedAt: null, | ||
| 50 | session: null, | ||
| 51 | latestUserMessageAt: null, | ||
| 52 | hasPendingApprovals: false, | ||
| 53 | hasPendingUserInput: false, | ||
| 54 | hasActionableProposedPlan: false, | ||
| 55 | }, | ||
| 56 | ], | ||
| 57 | }; | ||
| 58 | |||
| 59 | const TARGET = { environmentId: EnvironmentId.make("env-local") } as const; | ||
| 60 | |||
| 61 | describe("createShellSnapshotManager", () => { | ||
| 62 | afterEach(() => { | ||
| 63 | resetAtomRegistry(); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("starts pending when marked pending", () => { | ||
| 67 | const manager = createShellSnapshotManager({ | ||
| 68 | getRegistry: () => atomRegistry, | ||
| 69 | }); | ||
| 70 | |||
| 71 | manager.markPending(TARGET); | ||
| 72 | |||
| 73 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 74 | data: null, | ||
| 75 | error: null, | ||
| 76 | isPending: true, | ||
| 77 | }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("stores snapshots", () => { | ||
| 81 | const manager = createShellSnapshotManager({ | ||
| 82 | getRegistry: () => atomRegistry, | ||
| 83 | }); | ||
| 84 | |||
| 85 | manager.syncSnapshot(TARGET, BASE_SNAPSHOT); | ||
| 86 | |||
| 87 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 88 | data: BASE_SNAPSHOT, | ||
| 89 | error: null, | ||
| 90 | isPending: false, | ||
| 91 | }); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("applies incremental shell events", () => { | ||
| 95 | const manager = createShellSnapshotManager({ | ||
| 96 | getRegistry: () => atomRegistry, | ||
| 97 | }); | ||
| 98 | const existingThread = BASE_SNAPSHOT.threads[0]!; | ||
| 99 | |||
| 100 | manager.syncSnapshot(TARGET, BASE_SNAPSHOT); | ||
| 101 | manager.applyEvent(TARGET, { | ||
| 102 | kind: "thread-upserted", | ||
| 103 | sequence: 2, | ||
| 104 | thread: { | ||
| 105 | ...existingThread, | ||
| 106 | title: "Renamed thread", | ||
| 107 | }, | ||
| 108 | }); | ||
| 109 | |||
| 110 | expect(manager.getSnapshot(TARGET).data?.threads[0]?.title).toBe("Renamed thread"); | ||
| 111 | expect(manager.getSnapshot(TARGET).data?.snapshotSequence).toBe(2); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("invalidates per environment", () => { | ||
| 115 | const manager = createShellSnapshotManager({ | ||
| 116 | getRegistry: () => atomRegistry, | ||
| 117 | }); | ||
| 118 | |||
| 119 | manager.syncSnapshot(TARGET, BASE_SNAPSHOT); | ||
| 120 | manager.invalidate(TARGET); | ||
| 121 | |||
| 122 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 123 | data: null, | ||
| 124 | error: null, | ||
| 125 | isPending: false, | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | }); | ||
packages/client-runtime/src/shellSnapshotState.ts created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationShellSnapshot, | ||
| 4 | OrchestrationShellStreamEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 7 | |||
| 8 | import { applyShellStreamEvent } from "./shellSnapshotReducer.ts"; | ||
| 9 | |||
| 10 | export interface ShellSnapshotState { | ||
| 11 | readonly data: OrchestrationShellSnapshot | null; | ||
| 12 | readonly error: string | null; | ||
| 13 | readonly isPending: boolean; | ||
| 14 | } | ||
| 15 | |||
| 16 | export interface ShellSnapshotTarget { | ||
| 17 | readonly environmentId: EnvironmentId | null; | ||
| 18 | } | ||
| 19 | |||
| 20 | export const EMPTY_SHELL_SNAPSHOT_STATE = Object.freeze<ShellSnapshotState>({ | ||
| 21 | data: null, | ||
| 22 | error: null, | ||
| 23 | isPending: false, | ||
| 24 | }); | ||
| 25 | |||
| 26 | const INITIAL_SHELL_SNAPSHOT_STATE = Object.freeze<ShellSnapshotState>({ | ||
| 27 | data: null, | ||
| 28 | error: null, | ||
| 29 | isPending: true, | ||
| 30 | }); | ||
| 31 | |||
| 32 | const knownShellSnapshotKeys = new Set<string>(); | ||
| 33 | |||
| 34 | export const shellSnapshotStateAtom = Atom.family((key: string) => { | ||
| 35 | knownShellSnapshotKeys.add(key); | ||
| 36 | return Atom.make(INITIAL_SHELL_SNAPSHOT_STATE).pipe( | ||
| 37 | Atom.keepAlive, | ||
| 38 | Atom.withLabel(`shell-snapshot:${key}`), | ||
| 39 | ); | ||
| 40 | }); | ||
| 41 | |||
| 42 | export const EMPTY_SHELL_SNAPSHOT_ATOM = Atom.make(EMPTY_SHELL_SNAPSHOT_STATE).pipe( | ||
| 43 | Atom.keepAlive, | ||
| 44 | Atom.withLabel("shell-snapshot:null"), | ||
| 45 | ); | ||
| 46 | |||
| 47 | export function getShellSnapshotTargetKey(target: ShellSnapshotTarget): string | null { | ||
| 48 | return target.environmentId; | ||
| 49 | } | ||
| 50 | |||
| 51 | export interface ShellSnapshotManagerConfig { | ||
| 52 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 53 | } | ||
| 54 | |||
| 55 | export function createShellSnapshotManager(config: ShellSnapshotManagerConfig) { | ||
| 56 | function getSnapshot(target: ShellSnapshotTarget): ShellSnapshotState { | ||
| 57 | const targetKey = getShellSnapshotTargetKey(target); | ||
| 58 | if (targetKey === null) { | ||
| 59 | return EMPTY_SHELL_SNAPSHOT_STATE; | ||
| 60 | } | ||
| 61 | |||
| 62 | return config.getRegistry().get(shellSnapshotStateAtom(targetKey)); | ||
| 63 | } | ||
| 64 | |||
| 65 | function setState(targetKey: string, nextState: ShellSnapshotState): void { | ||
| 66 | config.getRegistry().set(shellSnapshotStateAtom(targetKey), nextState); | ||
| 67 | } | ||
| 68 | |||
| 69 | function markPending(target: ShellSnapshotTarget): void { | ||
| 70 | const targetKey = getShellSnapshotTargetKey(target); | ||
| 71 | if (targetKey === null) { | ||
| 72 | return; | ||
| 73 | } | ||
| 74 | |||
| 75 | const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey)); | ||
| 76 | setState(targetKey, { | ||
| 77 | data: current.data, | ||
| 78 | error: null, | ||
| 79 | isPending: true, | ||
| 80 | }); | ||
| 81 | } | ||
| 82 | |||
| 83 | function syncSnapshot(target: ShellSnapshotTarget, snapshot: OrchestrationShellSnapshot): void { | ||
| 84 | const targetKey = getShellSnapshotTargetKey(target); | ||
| 85 | if (targetKey === null) { | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | |||
| 89 | setState(targetKey, { | ||
| 90 | data: snapshot, | ||
| 91 | error: null, | ||
| 92 | isPending: false, | ||
| 93 | }); | ||
| 94 | } | ||
| 95 | |||
| 96 | function applyEvent(target: ShellSnapshotTarget, event: OrchestrationShellStreamEvent): void { | ||
| 97 | const targetKey = getShellSnapshotTargetKey(target); | ||
| 98 | if (targetKey === null) { | ||
| 99 | return; | ||
| 100 | } | ||
| 101 | |||
| 102 | const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey)); | ||
| 103 | if (current.data === null) { | ||
| 104 | return; | ||
| 105 | } | ||
| 106 | |||
| 107 | setState(targetKey, { | ||
| 108 | data: applyShellStreamEvent(current.data, event), | ||
| 109 | error: null, | ||
| 110 | isPending: false, | ||
| 111 | }); | ||
| 112 | } | ||
| 113 | |||
| 114 | function invalidate(target?: ShellSnapshotTarget): void { | ||
| 115 | if (target) { | ||
| 116 | const targetKey = getShellSnapshotTargetKey(target); | ||
| 117 | if (targetKey !== null) { | ||
| 118 | setState(targetKey, EMPTY_SHELL_SNAPSHOT_STATE); | ||
| 119 | } | ||
| 120 | return; | ||
| 121 | } | ||
| 122 | |||
| 123 | for (const key of knownShellSnapshotKeys) { | ||
| 124 | setState(key, EMPTY_SHELL_SNAPSHOT_STATE); | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | function reset(): void { | ||
| 129 | invalidate(); | ||
| 130 | } | ||
| 131 | |||
| 132 | return { | ||
| 133 | markPending, | ||
| 134 | syncSnapshot, | ||
| 135 | applyEvent, | ||
| 136 | getSnapshot, | ||
| 137 | invalidate, | ||
| 138 | reset, | ||
| 139 | }; | ||
| 140 | } | ||
packages/client-runtime/src/shellTypes.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationProjectShell, | ||
| 4 | OrchestrationShellSnapshot, | ||
| 5 | OrchestrationThreadShell, | ||
| 6 | ThreadId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | export interface EnvironmentScopedProjectShell extends OrchestrationProjectShell { | ||
| 10 | readonly environmentId: EnvironmentId; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface EnvironmentScopedThreadShell extends OrchestrationThreadShell { | ||
| 14 | readonly environmentId: EnvironmentId; | ||
| 15 | } | ||
| 16 | |||
| 17 | export function scopeProjectShell( | ||
| 18 | environmentId: EnvironmentId, | ||
| 19 | project: OrchestrationProjectShell, | ||
| 20 | ): EnvironmentScopedProjectShell { | ||
| 21 | return { ...project, environmentId }; | ||
| 22 | } | ||
| 23 | |||
| 24 | export function scopeThreadShell( | ||
| 25 | environmentId: EnvironmentId, | ||
| 26 | thread: OrchestrationThreadShell, | ||
| 27 | ): EnvironmentScopedThreadShell { | ||
| 28 | return { ...thread, environmentId }; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function selectScopedThreadShell( | ||
| 32 | snapshot: OrchestrationShellSnapshot | null, | ||
| 33 | environmentId: EnvironmentId, | ||
| 34 | threadId: ThreadId, | ||
| 35 | ): EnvironmentScopedThreadShell | null { | ||
| 36 | const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; | ||
| 37 | return thread ? scopeThreadShell(environmentId, thread) : null; | ||
| 38 | } | ||
packages/client-runtime/src/sourceControlDiscoveryState.test.ts+202| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import { assert, beforeEach, it } from "vitest"; | 1 | import { assert, beforeEach, it } from "vitest"; |
| 2 | import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | 2 | import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; |
| 3 | import * as Option from "effect/Option"; | ||
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | 4 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 5 | ||
| 5 | import { | 6 | import { |
| ... | @@ -12,17 +13,53 @@ const EMPTY_RESULT: SourceControlDiscoveryResult = { | ... | @@ -12,17 +13,53 @@ const EMPTY_RESULT: SourceControlDiscoveryResult = { |
| 12 | sourceControlProviders: [], | 13 | sourceControlProviders: [], |
| 13 | }; | 14 | }; |
| 14 | 15 | ||
| 16 | const GITHUB_RESULT: SourceControlDiscoveryResult = { | ||
| 17 | versionControlSystems: [ | ||
| 18 | { | ||
| 19 | kind: "git", | ||
| 20 | label: "Git", | ||
| 21 | implemented: true, | ||
| 22 | status: "available", | ||
| 23 | version: Option.some("2.51.0"), | ||
| 24 | installHint: "Install Git.", | ||
| 25 | detail: Option.none(), | ||
| 26 | }, | ||
| 27 | ], | ||
| 28 | sourceControlProviders: [ | ||
| 29 | { | ||
| 30 | kind: "github", | ||
| 31 | label: "GitHub", | ||
| 32 | status: "available", | ||
| 33 | version: Option.some("2.85.0"), | ||
| 34 | installHint: "Install GitHub CLI.", | ||
| 35 | detail: Option.none(), | ||
| 36 | auth: { | ||
| 37 | status: "authenticated", | ||
| 38 | account: Option.some("octo"), | ||
| 39 | host: Option.some("github.com"), | ||
| 40 | detail: Option.none(), | ||
| 41 | }, | ||
| 42 | }, | ||
| 43 | ], | ||
| 44 | }; | ||
| 45 | |||
| 15 | function unresolvedDiscovery() { | 46 | function unresolvedDiscovery() { |
| 16 | throw new Error("Discovery resolver was not initialized."); | 47 | throw new Error("Discovery resolver was not initialized."); |
| 17 | } | 48 | } |
| 18 | 49 | ||
| 19 | let registry = AtomRegistry.make(); | 50 | let registry = AtomRegistry.make(); |
| 20 | 51 | ||
| 52 | const noop = () => undefined; | ||
| 53 | |||
| 21 | beforeEach(() => { | 54 | beforeEach(() => { |
| 22 | registry.dispose(); | 55 | registry.dispose(); |
| 23 | registry = AtomRegistry.make(); | 56 | registry = AtomRegistry.make(); |
| 24 | }); | 57 | }); |
| 25 | 58 | ||
| 59 | function flushAsyncWork(): Promise<void> { | ||
| 60 | return Promise.resolve().then(() => undefined); | ||
| 61 | } | ||
| 62 | |||
| 26 | it("stores refreshed discovery data in an atom snapshot", async () => { | 63 | it("stores refreshed discovery data in an atom snapshot", async () => { |
| 27 | const manager = createSourceControlDiscoveryManager({ | 64 | const manager = createSourceControlDiscoveryManager({ |
| 28 | getRegistry: () => registry, | 65 | getRegistry: () => registry, |
| ... | @@ -105,3 +142,168 @@ it("keeps the previous snapshot when refresh fails", async () => { | ... | @@ -105,3 +142,168 @@ it("keeps the previous snapshot when refresh fails", async () => { |
| 105 | isPending: false, | 142 | isPending: false, |
| 106 | }); | 143 | }); |
| 107 | }); | 144 | }); |
| 145 | |||
| 146 | it("invalidates a discovery target back to the initial snapshot", async () => { | ||
| 147 | const manager = createSourceControlDiscoveryManager({ | ||
| 148 | getRegistry: () => registry, | ||
| 149 | getClient: () => ({ | ||
| 150 | discoverSourceControl: async () => GITHUB_RESULT, | ||
| 151 | }), | ||
| 152 | }); | ||
| 153 | |||
| 154 | await manager.refresh({ key: "primary" }); | ||
| 155 | manager.invalidate({ key: "primary" }); | ||
| 156 | |||
| 157 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 158 | data: null, | ||
| 159 | error: null, | ||
| 160 | isPending: true, | ||
| 161 | }); | ||
| 162 | }); | ||
| 163 | |||
| 164 | it("ignores an in-flight refresh after the target is invalidated", async () => { | ||
| 165 | let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; | ||
| 166 | const manager = createSourceControlDiscoveryManager({ | ||
| 167 | getRegistry: () => registry, | ||
| 168 | getClient: () => ({ | ||
| 169 | discoverSourceControl: () => | ||
| 170 | new Promise<SourceControlDiscoveryResult>((resolve) => { | ||
| 171 | resolveDiscovery = resolve; | ||
| 172 | }), | ||
| 173 | }), | ||
| 174 | }); | ||
| 175 | |||
| 176 | const refresh = manager.refresh({ key: "primary" }); | ||
| 177 | manager.invalidate({ key: "primary" }); | ||
| 178 | resolveDiscovery(GITHUB_RESULT); | ||
| 179 | |||
| 180 | const result = await refresh; | ||
| 181 | |||
| 182 | assert.strictEqual(result, GITHUB_RESULT); | ||
| 183 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 184 | data: null, | ||
| 185 | error: null, | ||
| 186 | isPending: true, | ||
| 187 | }); | ||
| 188 | }); | ||
| 189 | |||
| 190 | it("watches a discovery target with ref-counted client-change subscriptions", async () => { | ||
| 191 | let listener: () => void = noop; | ||
| 192 | let subscribeCalls = 0; | ||
| 193 | let unsubscribeCalls = 0; | ||
| 194 | let discoveryCalls = 0; | ||
| 195 | const client = { | ||
| 196 | discoverSourceControl: async () => { | ||
| 197 | discoveryCalls += 1; | ||
| 198 | return EMPTY_RESULT; | ||
| 199 | }, | ||
| 200 | }; | ||
| 201 | const manager = createSourceControlDiscoveryManager({ | ||
| 202 | getRegistry: () => registry, | ||
| 203 | getClient: () => client, | ||
| 204 | subscribeClientChanges: (nextListener) => { | ||
| 205 | subscribeCalls += 1; | ||
| 206 | listener = nextListener; | ||
| 207 | return () => { | ||
| 208 | unsubscribeCalls += 1; | ||
| 209 | }; | ||
| 210 | }, | ||
| 211 | }); | ||
| 212 | |||
| 213 | const firstUnwatch = manager.watch({ key: "primary" }); | ||
| 214 | const secondUnwatch = manager.watch({ key: "primary" }); | ||
| 215 | await flushAsyncWork(); | ||
| 216 | |||
| 217 | assert.strictEqual(subscribeCalls, 1); | ||
| 218 | assert.strictEqual(discoveryCalls, 1); | ||
| 219 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 220 | data: EMPTY_RESULT, | ||
| 221 | error: null, | ||
| 222 | isPending: false, | ||
| 223 | }); | ||
| 224 | |||
| 225 | listener(); | ||
| 226 | await flushAsyncWork(); | ||
| 227 | assert.strictEqual(discoveryCalls, 1); | ||
| 228 | |||
| 229 | firstUnwatch(); | ||
| 230 | assert.strictEqual(unsubscribeCalls, 0); | ||
| 231 | |||
| 232 | secondUnwatch(); | ||
| 233 | assert.strictEqual(unsubscribeCalls, 1); | ||
| 234 | }); | ||
| 235 | |||
| 236 | it("reuses fresh watched discovery results on remount", async () => { | ||
| 237 | let discoveryCalls = 0; | ||
| 238 | const client = { | ||
| 239 | discoverSourceControl: async () => { | ||
| 240 | discoveryCalls += 1; | ||
| 241 | return EMPTY_RESULT; | ||
| 242 | }, | ||
| 243 | }; | ||
| 244 | const manager = createSourceControlDiscoveryManager({ | ||
| 245 | getRegistry: () => registry, | ||
| 246 | getClient: () => client, | ||
| 247 | staleTimeMs: 60_000, | ||
| 248 | }); | ||
| 249 | |||
| 250 | const firstUnwatch = manager.watch({ key: "primary" }); | ||
| 251 | await flushAsyncWork(); | ||
| 252 | firstUnwatch(); | ||
| 253 | |||
| 254 | const secondUnwatch = manager.watch({ key: "primary" }); | ||
| 255 | await flushAsyncWork(); | ||
| 256 | secondUnwatch(); | ||
| 257 | |||
| 258 | assert.strictEqual(discoveryCalls, 1); | ||
| 259 | }); | ||
| 260 | |||
| 261 | it("refreshes a watched discovery target when the resolved client is replaced", async () => { | ||
| 262 | let listener: () => void = noop; | ||
| 263 | let activeResult = EMPTY_RESULT; | ||
| 264 | let discoveryCalls = 0; | ||
| 265 | const firstClient = { | ||
| 266 | discoverSourceControl: async () => { | ||
| 267 | discoveryCalls += 1; | ||
| 268 | return activeResult; | ||
| 269 | }, | ||
| 270 | }; | ||
| 271 | const secondClient = { | ||
| 272 | discoverSourceControl: async () => { | ||
| 273 | discoveryCalls += 1; | ||
| 274 | return activeResult; | ||
| 275 | }, | ||
| 276 | }; | ||
| 277 | let activeClient = firstClient; | ||
| 278 | const manager = createSourceControlDiscoveryManager({ | ||
| 279 | getRegistry: () => registry, | ||
| 280 | getClient: () => activeClient, | ||
| 281 | subscribeClientChanges: (nextListener) => { | ||
| 282 | listener = nextListener; | ||
| 283 | return () => undefined; | ||
| 284 | }, | ||
| 285 | }); | ||
| 286 | |||
| 287 | const unwatch = manager.watch({ key: "primary" }); | ||
| 288 | await flushAsyncWork(); | ||
| 289 | |||
| 290 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 291 | data: EMPTY_RESULT, | ||
| 292 | error: null, | ||
| 293 | isPending: false, | ||
| 294 | }); | ||
| 295 | |||
| 296 | activeClient = secondClient; | ||
| 297 | activeResult = GITHUB_RESULT; | ||
| 298 | listener(); | ||
| 299 | await flushAsyncWork(); | ||
| 300 | |||
| 301 | assert.strictEqual(discoveryCalls, 2); | ||
| 302 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 303 | data: GITHUB_RESULT, | ||
| 304 | error: null, | ||
| 305 | isPending: false, | ||
| 306 | }); | ||
| 307 | |||
| 308 | unwatch(); | ||
| 309 | }); |
packages/client-runtime/src/sourceControlDiscoveryState.ts+218-23| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | 1 | import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 2 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | 3 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; |
| 3 | 4 | ||
| 4 | /* --- Types ---------------------------------------------------------- */ | 5 | /* --- Types ---------------------------------------------------------- */ |
| ... | @@ -9,14 +10,19 @@ export interface SourceControlDiscoveryState { | ... | @@ -9,14 +10,19 @@ export interface SourceControlDiscoveryState { |
| 9 | readonly isPending: boolean; | 10 | readonly isPending: boolean; |
| 10 | } | 11 | } |
| 11 | 12 | ||
| 12 | export interface SourceControlDiscoveryTarget { | 13 | export interface SourceControlDiscoveryTarget<TKey extends string = string> { |
| 13 | readonly key: string | null; | 14 | readonly key: TKey | null; |
| 14 | } | 15 | } |
| 15 | 16 | ||
| 16 | export interface SourceControlDiscoveryClient { | 17 | export interface SourceControlDiscoveryClient { |
| 17 | readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>; | 18 | readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>; |
| 18 | } | 19 | } |
| 19 | 20 | ||
| 21 | interface WatchedEntry { | ||
| 22 | refCount: number; | ||
| 23 | teardown: () => void; | ||
| 24 | } | ||
| 25 | |||
| 20 | /* --- Constants ------------------------------------------------------ */ | 26 | /* --- Constants ------------------------------------------------------ */ |
| 21 | 27 | ||
| 22 | export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({ | 28 | export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({ |
| ... | @@ -49,16 +55,16 @@ export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make( | ... | @@ -49,16 +55,16 @@ export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make( |
| 49 | 55 | ||
| 50 | /* --- Helpers -------------------------------------------------------- */ | 56 | /* --- Helpers -------------------------------------------------------- */ |
| 51 | 57 | ||
| 52 | export function getSourceControlDiscoveryTargetKey( | 58 | export function getSourceControlDiscoveryTargetKey<TKey extends string>( |
| 53 | target: SourceControlDiscoveryTarget, | 59 | target: SourceControlDiscoveryTarget<TKey>, |
| 54 | ): string | null { | 60 | ): TKey | null { |
| 55 | const key = target.key?.trim(); | 61 | const key = target.key; |
| 56 | return key && key.length > 0 ? key : null; | 62 | return key && key.length > 0 ? key : null; |
| 57 | } | 63 | } |
| 58 | 64 | ||
| 59 | /* --- Refresh manager ------------------------------------------------ */ | 65 | /* --- Refresh manager ------------------------------------------------ */ |
| 60 | 66 | ||
| 61 | export interface SourceControlDiscoveryManagerConfig { | 67 | export interface SourceControlDiscoveryManagerConfig<TKey extends string = string> { |
| 62 | /** | 68 | /** |
| 63 | * Get the atom registry used to read/write source-control discovery snapshots. | 69 | * Get the atom registry used to read/write source-control discovery snapshots. |
| 64 | */ | 70 | */ |
| ... | @@ -70,11 +76,61 @@ export interface SourceControlDiscoveryManagerConfig { | ... | @@ -70,11 +76,61 @@ export interface SourceControlDiscoveryManagerConfig { |
| 70 | * lets mobile or future multi-environment clients provide separate discovery | 76 | * lets mobile or future multi-environment clients provide separate discovery |
| 71 | * clients without changing the state primitive. | 77 | * clients without changing the state primitive. |
| 72 | */ | 78 | */ |
| 73 | readonly getClient: (key: string) => SourceControlDiscoveryClient | null; | 79 | readonly getClient: (key: TKey) => SourceControlDiscoveryClient | null; |
| 80 | /** | ||
| 81 | * Optional: subscribe to environment/client availability changes. | ||
| 82 | * | ||
| 83 | * When provided, `watch` refreshes as clients appear or are replaced | ||
| 84 | * instead of relying on React hooks to manually kick discovery. | ||
| 85 | */ | ||
| 86 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 87 | readonly staleTimeMs?: number; | ||
| 88 | readonly idleTtlMs?: number; | ||
| 74 | } | 89 | } |
| 75 | 90 | ||
| 76 | export function createSourceControlDiscoveryManager(config: SourceControlDiscoveryManagerConfig) { | 91 | const NOOP: () => void = () => undefined; |
| 77 | const refreshInFlight = new Map<string, Promise<SourceControlDiscoveryResult | null>>(); | 92 | const DEFAULT_STALE_TIME_MS = 30_000; |
| 93 | const DEFAULT_IDLE_TTL_MS = 5 * 60_000; | ||
| 94 | |||
| 95 | export function createSourceControlDiscoveryManager<TKey extends string = string>( | ||
| 96 | config: SourceControlDiscoveryManagerConfig<TKey>, | ||
| 97 | ) { | ||
| 98 | const refreshInFlight = new Map< | ||
| 99 | string, | ||
| 100 | { | ||
| 101 | readonly client: SourceControlDiscoveryClient; | ||
| 102 | readonly promise: Promise<SourceControlDiscoveryResult | null>; | ||
| 103 | } | ||
| 104 | >(); | ||
| 105 | const refreshVersions = new Map<string, number>(); | ||
| 106 | const watched = new Map<string, WatchedEntry>(); | ||
| 107 | const refreshTargets = new Map<string, SourceControlDiscoveryTarget<TKey>>(); | ||
| 108 | const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS; | ||
| 109 | const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; | ||
| 110 | |||
| 111 | const watchedRefreshAtom = Atom.family((targetKey: string) => | ||
| 112 | Atom.make(() => | ||
| 113 | Effect.promise(() => { | ||
| 114 | const target = refreshTargets.get(targetKey); | ||
| 115 | return target ? refresh(target) : Promise.resolve(null); | ||
| 116 | }), | ||
| 117 | ).pipe( | ||
| 118 | Atom.swr({ | ||
| 119 | staleTime: staleTimeMs, | ||
| 120 | revalidateOnMount: true, | ||
| 121 | }), | ||
| 122 | Atom.setIdleTTL(idleTtlMs), | ||
| 123 | Atom.withLabel(`source-control-discovery:watched-refresh:${targetKey}`), | ||
| 124 | ), | ||
| 125 | ); | ||
| 126 | |||
| 127 | function getRefreshVersion(targetKey: string): number { | ||
| 128 | return refreshVersions.get(targetKey) ?? 0; | ||
| 129 | } | ||
| 130 | |||
| 131 | function bumpRefreshVersion(targetKey: string): void { | ||
| 132 | refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1); | ||
| 133 | } | ||
| 78 | 134 | ||
| 79 | /* -- Atom helpers -------------------------------------------------- */ | 135 | /* -- Atom helpers -------------------------------------------------- */ |
| 80 | 136 | ||
| ... | @@ -135,18 +191,14 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove | ... | @@ -135,18 +191,14 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove |
| 135 | * @param client Optional pre-resolved client, useful in tests. | 191 | * @param client Optional pre-resolved client, useful in tests. |
| 136 | */ | 192 | */ |
| 137 | function refresh( | 193 | function refresh( |
| 138 | target: SourceControlDiscoveryTarget, | 194 | target: SourceControlDiscoveryTarget<TKey>, |
| 139 | client?: SourceControlDiscoveryClient, | 195 | client?: SourceControlDiscoveryClient, |
| 140 | ): Promise<SourceControlDiscoveryResult | null> { | 196 | ): Promise<SourceControlDiscoveryResult | null> { |
| 141 | const targetKey = getSourceControlDiscoveryTargetKey(target); | 197 | const targetKey = getSourceControlDiscoveryTargetKey(target); |
| 142 | if (targetKey === null) { | 198 | if (targetKey === null) { |
| 143 | return Promise.resolve(null); | 199 | return Promise.resolve(null); |
| 144 | } | 200 | } |
| 145 | 201 | refreshTargets.set(targetKey, target); | |
| 146 | const existing = refreshInFlight.get(targetKey); | ||
| 147 | if (existing) { | ||
| 148 | return existing; | ||
| 149 | } | ||
| 150 | 202 | ||
| 151 | const resolvedClient = client ?? config.getClient(targetKey); | 203 | const resolvedClient = client ?? config.getClient(targetKey); |
| 152 | if (!resolvedClient) { | 204 | if (!resolvedClient) { |
| ... | @@ -155,29 +207,72 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove | ... | @@ -155,29 +207,72 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove |
| 155 | return Promise.resolve(getSnapshot(target).data); | 207 | return Promise.resolve(getSnapshot(target).data); |
| 156 | } | 208 | } |
| 157 | 209 | ||
| 210 | const existing = refreshInFlight.get(targetKey); | ||
| 211 | if (existing) { | ||
| 212 | if (!client || existing.client === resolvedClient) { | ||
| 213 | return existing.promise; | ||
| 214 | } | ||
| 215 | |||
| 216 | return existing.promise.then(() => refresh(target, resolvedClient)); | ||
| 217 | } | ||
| 218 | |||
| 158 | markPending(targetKey); | 219 | markPending(targetKey); |
| 220 | const refreshVersion = getRefreshVersion(targetKey); | ||
| 159 | const promise = resolvedClient.discoverSourceControl().then( | 221 | const promise = resolvedClient.discoverSourceControl().then( |
| 160 | (result) => { | 222 | (result) => { |
| 161 | setData(targetKey, result); | 223 | if (getRefreshVersion(targetKey) === refreshVersion) { |
| 224 | setData(targetKey, result); | ||
| 225 | } | ||
| 162 | return result; | 226 | return result; |
| 163 | }, | 227 | }, |
| 164 | (error: unknown) => { | 228 | (error: unknown) => { |
| 165 | setError(targetKey, error); | 229 | if (getRefreshVersion(targetKey) === refreshVersion) { |
| 230 | setError(targetKey, error); | ||
| 231 | } | ||
| 166 | return getSnapshot(target).data; | 232 | return getSnapshot(target).data; |
| 167 | }, | 233 | }, |
| 168 | ); | 234 | ); |
| 169 | const tracked = promise.finally(() => refreshInFlight.delete(targetKey)); | 235 | let tracked: Promise<SourceControlDiscoveryResult | null>; |
| 170 | refreshInFlight.set(targetKey, tracked); | 236 | tracked = promise.finally(() => { |
| 237 | if (refreshInFlight.get(targetKey)?.promise === tracked) { | ||
| 238 | refreshInFlight.delete(targetKey); | ||
| 239 | } | ||
| 240 | }); | ||
| 241 | refreshInFlight.set(targetKey, { | ||
| 242 | client: resolvedClient, | ||
| 243 | promise: tracked, | ||
| 244 | }); | ||
| 171 | return tracked; | 245 | return tracked; |
| 172 | } | 246 | } |
| 173 | 247 | ||
| 248 | /** | ||
| 249 | * Reset discovery state for one target and ignore any currently in-flight | ||
| 250 | * refresh for that target. If no target is provided, every known target is | ||
| 251 | * invalidated. | ||
| 252 | */ | ||
| 253 | function invalidate(target?: SourceControlDiscoveryTarget<TKey>): void { | ||
| 254 | if (!target) { | ||
| 255 | reset(); | ||
| 256 | return; | ||
| 257 | } | ||
| 258 | |||
| 259 | const targetKey = getSourceControlDiscoveryTargetKey(target); | ||
| 260 | if (targetKey === null) { | ||
| 261 | return; | ||
| 262 | } | ||
| 263 | |||
| 264 | bumpRefreshVersion(targetKey); | ||
| 265 | refreshInFlight.delete(targetKey); | ||
| 266 | setState(targetKey, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); | ||
| 267 | } | ||
| 268 | |||
| 174 | /** | 269 | /** |
| 175 | * Read the current atom snapshot for `target`. | 270 | * Read the current atom snapshot for `target`. |
| 176 | * | 271 | * |
| 177 | * Invalid targets return the inert empty state rather than creating a new | 272 | * Invalid targets return the inert empty state rather than creating a new |
| 178 | * family atom entry. | 273 | * family atom entry. |
| 179 | */ | 274 | */ |
| 180 | function getSnapshot(target: SourceControlDiscoveryTarget): SourceControlDiscoveryState { | 275 | function getSnapshot(target: SourceControlDiscoveryTarget<TKey>): SourceControlDiscoveryState { |
| 181 | const targetKey = getSourceControlDiscoveryTargetKey(target); | 276 | const targetKey = getSourceControlDiscoveryTargetKey(target); |
| 182 | if (targetKey === null) { | 277 | if (targetKey === null) { |
| 183 | return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE; | 278 | return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE; |
| ... | @@ -186,21 +281,121 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove | ... | @@ -186,21 +281,121 @@ export function createSourceControlDiscoveryManager(config: SourceControlDiscove |
| 186 | return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); | 281 | return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); |
| 187 | } | 282 | } |
| 188 | 283 | ||
| 284 | /** | ||
| 285 | * Keep discovery warm for `target`. | ||
| 286 | * | ||
| 287 | * Multiple callers sharing a target key are ref-counted. With | ||
| 288 | * `subscribeClientChanges`, the manager refreshes whenever a client first | ||
| 289 | * appears or is replaced after reconnect. | ||
| 290 | */ | ||
| 291 | function watch( | ||
| 292 | target: SourceControlDiscoveryTarget<TKey>, | ||
| 293 | client?: SourceControlDiscoveryClient, | ||
| 294 | ): () => void { | ||
| 295 | const targetKey = getSourceControlDiscoveryTargetKey(target); | ||
| 296 | if (targetKey === null) { | ||
| 297 | return NOOP; | ||
| 298 | } | ||
| 299 | refreshTargets.set(targetKey, target); | ||
| 300 | |||
| 301 | const existing = watched.get(targetKey); | ||
| 302 | if (existing) { | ||
| 303 | existing.refCount += 1; | ||
| 304 | return () => unwatch(targetKey); | ||
| 305 | } | ||
| 306 | |||
| 307 | let teardown: () => void; | ||
| 308 | |||
| 309 | if (client) { | ||
| 310 | void refresh(target, client); | ||
| 311 | teardown = NOOP; | ||
| 312 | } else if (config.subscribeClientChanges) { | ||
| 313 | let currentClient: SourceControlDiscoveryClient | null = null; | ||
| 314 | |||
| 315 | const sync = () => { | ||
| 316 | const resolved = config.getClient(targetKey); | ||
| 317 | if (!resolved) { | ||
| 318 | currentClient = null; | ||
| 319 | markPending(targetKey); | ||
| 320 | return; | ||
| 321 | } | ||
| 322 | |||
| 323 | if (currentClient === resolved) { | ||
| 324 | return; | ||
| 325 | } | ||
| 326 | |||
| 327 | const isClientReplacement = currentClient !== null; | ||
| 328 | currentClient = resolved; | ||
| 329 | refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined); | ||
| 330 | }; | ||
| 331 | |||
| 332 | const unsubChanges = config.subscribeClientChanges(sync); | ||
| 333 | sync(); | ||
| 334 | teardown = unsubChanges; | ||
| 335 | } else { | ||
| 336 | if (!config.getClient(targetKey)) { | ||
| 337 | return NOOP; | ||
| 338 | } | ||
| 339 | refreshWatchedTarget(targetKey, target); | ||
| 340 | teardown = NOOP; | ||
| 341 | } | ||
| 342 | |||
| 343 | watched.set(targetKey, { refCount: 1, teardown }); | ||
| 344 | return () => unwatch(targetKey); | ||
| 345 | } | ||
| 346 | |||
| 347 | function unwatch(targetKey: string): void { | ||
| 348 | const entry = watched.get(targetKey); | ||
| 349 | if (!entry) { | ||
| 350 | return; | ||
| 351 | } | ||
| 352 | |||
| 353 | entry.refCount -= 1; | ||
| 354 | if (entry.refCount > 0) { | ||
| 355 | return; | ||
| 356 | } | ||
| 357 | |||
| 358 | entry.teardown(); | ||
| 359 | watched.delete(targetKey); | ||
| 360 | } | ||
| 361 | |||
| 362 | function refreshWatchedTarget( | ||
| 363 | targetKey: string, | ||
| 364 | target: SourceControlDiscoveryTarget<TKey>, | ||
| 365 | client?: SourceControlDiscoveryClient, | ||
| 366 | ): void { | ||
| 367 | refreshTargets.set(targetKey, target); | ||
| 368 | if (client) { | ||
| 369 | void refresh(target, client); | ||
| 370 | return; | ||
| 371 | } | ||
| 372 | |||
| 373 | config.getRegistry().get(watchedRefreshAtom(targetKey)); | ||
| 374 | } | ||
| 375 | |||
| 189 | /** | 376 | /** |
| 190 | * Clear in-flight refresh tracking and reset every known discovery atom. | 377 | * Clear in-flight refresh tracking and reset every known discovery atom. |
| 191 | * Primarily used by tests and runtime teardown. | 378 | * Primarily used by tests and runtime teardown. |
| 192 | */ | 379 | */ |
| 193 | function reset(): void { | 380 | function reset(): void { |
| 381 | const keys = new Set([...knownSourceControlDiscoveryKeys, ...refreshInFlight.keys()]); | ||
| 382 | for (const entry of watched.values()) { | ||
| 383 | entry.teardown(); | ||
| 384 | } | ||
| 385 | watched.clear(); | ||
| 386 | refreshTargets.clear(); | ||
| 194 | refreshInFlight.clear(); | 387 | refreshInFlight.clear(); |
| 195 | for (const key of knownSourceControlDiscoveryKeys) { | 388 | for (const key of keys) { |
| 389 | bumpRefreshVersion(key); | ||
| 196 | setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); | 390 | setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); |
| 197 | } | 391 | } |
| 198 | knownSourceControlDiscoveryKeys.clear(); | ||
| 199 | } | 392 | } |
| 200 | 393 | ||
| 201 | return { | 394 | return { |
| 395 | watch, | ||
| 202 | refresh, | 396 | refresh, |
| 203 | getSnapshot, | 397 | getSnapshot, |
| 398 | invalidate, | ||
| 204 | reset, | 399 | reset, |
| 205 | }; | 400 | }; |
| 206 | } | 401 | } |
packages/client-runtime/src/terminalSessionState.test.ts created+558| ... | @@ -0,0 +1,558 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | EnvironmentId, | ||
| 6 | TerminalAttachStreamEvent, | ||
| 7 | TerminalMetadataStreamEvent, | ||
| 8 | TerminalSessionSnapshot, | ||
| 9 | ThreadId, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | createTerminalSessionManager, | ||
| 14 | getKnownTerminalSessionListFilter, | ||
| 15 | knownTerminalSessionsAtom, | ||
| 16 | runningTerminalIdsAtom, | ||
| 17 | terminalSessionStateAtom, | ||
| 18 | type KnownTerminalSessionTarget, | ||
| 19 | } from "./terminalSessionState.ts"; | ||
| 20 | |||
| 21 | let atomRegistry = AtomRegistry.make(); | ||
| 22 | |||
| 23 | function resetAtomRegistry() { | ||
| 24 | atomRegistry.dispose(); | ||
| 25 | atomRegistry = AtomRegistry.make(); | ||
| 26 | } | ||
| 27 | |||
| 28 | const TARGET = { | ||
| 29 | environmentId: EnvironmentId.make("env-local"), | ||
| 30 | threadId: ThreadId.make("thread-1"), | ||
| 31 | terminalId: "term-1", | ||
| 32 | } as const; | ||
| 33 | |||
| 34 | const BASE_SNAPSHOT: TerminalSessionSnapshot = { | ||
| 35 | threadId: TARGET.threadId, | ||
| 36 | terminalId: TARGET.terminalId, | ||
| 37 | cwd: "/repo", | ||
| 38 | worktreePath: null, | ||
| 39 | status: "running", | ||
| 40 | pid: 123, | ||
| 41 | history: "hello", | ||
| 42 | exitCode: null, | ||
| 43 | exitSignal: null, | ||
| 44 | label: "Terminal 1", | ||
| 45 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 46 | }; | ||
| 47 | |||
| 48 | type TerminalSessionManager = ReturnType<typeof createTerminalSessionManager>; | ||
| 49 | |||
| 50 | function applyAttachEvents( | ||
| 51 | manager: TerminalSessionManager, | ||
| 52 | target: KnownTerminalSessionTarget, | ||
| 53 | events: ReadonlyArray<TerminalAttachStreamEvent>, | ||
| 54 | ): void { | ||
| 55 | manager.attach({ | ||
| 56 | environmentId: target.environmentId, | ||
| 57 | terminal: { | ||
| 58 | threadId: target.threadId, | ||
| 59 | terminalId: target.terminalId, | ||
| 60 | }, | ||
| 61 | client: { | ||
| 62 | terminal: { | ||
| 63 | attach: (_input, listener) => { | ||
| 64 | events.forEach(listener); | ||
| 65 | return () => undefined; | ||
| 66 | }, | ||
| 67 | }, | ||
| 68 | }, | ||
| 69 | })(); | ||
| 70 | } | ||
| 71 | |||
| 72 | function applyMetadataEvents( | ||
| 73 | manager: TerminalSessionManager, | ||
| 74 | environmentId: EnvironmentId, | ||
| 75 | events: ReadonlyArray<TerminalMetadataStreamEvent>, | ||
| 76 | ): void { | ||
| 77 | manager.subscribeMetadata({ | ||
| 78 | environmentId, | ||
| 79 | client: { | ||
| 80 | terminal: { | ||
| 81 | onMetadata: (listener) => { | ||
| 82 | events.forEach(listener); | ||
| 83 | return () => undefined; | ||
| 84 | }, | ||
| 85 | }, | ||
| 86 | }, | ||
| 87 | })(); | ||
| 88 | } | ||
| 89 | |||
| 90 | describe("createTerminalSessionManager", () => { | ||
| 91 | afterEach(() => { | ||
| 92 | resetAtomRegistry(); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("hydrates from started snapshots and appends output events", () => { | ||
| 96 | const manager = createTerminalSessionManager({ | ||
| 97 | getRegistry: () => atomRegistry, | ||
| 98 | }); | ||
| 99 | |||
| 100 | applyAttachEvents(manager, TARGET, [ | ||
| 101 | { | ||
| 102 | type: "snapshot", | ||
| 103 | snapshot: BASE_SNAPSHOT, | ||
| 104 | }, | ||
| 105 | { | ||
| 106 | type: "output", | ||
| 107 | threadId: TARGET.threadId, | ||
| 108 | terminalId: TARGET.terminalId, | ||
| 109 | data: " world", | ||
| 110 | }, | ||
| 111 | ]); | ||
| 112 | |||
| 113 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 114 | summary: null, | ||
| 115 | buffer: "hello world", | ||
| 116 | status: "running", | ||
| 117 | error: null, | ||
| 118 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("caps retained output", () => { | ||
| 123 | const manager = createTerminalSessionManager({ | ||
| 124 | getRegistry: () => atomRegistry, | ||
| 125 | maxBufferBytes: 5, | ||
| 126 | }); | ||
| 127 | |||
| 128 | applyAttachEvents(manager, TARGET, [ | ||
| 129 | { | ||
| 130 | type: "output", | ||
| 131 | threadId: TARGET.threadId, | ||
| 132 | terminalId: TARGET.terminalId, | ||
| 133 | data: "abcdef", | ||
| 134 | }, | ||
| 135 | ]); | ||
| 136 | |||
| 137 | expect(manager.getSnapshot(TARGET).buffer).toBe("bcdef"); | ||
| 138 | }); | ||
| 139 | |||
| 140 | it("caps retained output by utf-8 byte length", () => { | ||
| 141 | const manager = createTerminalSessionManager({ | ||
| 142 | getRegistry: () => atomRegistry, | ||
| 143 | maxBufferBytes: 4, | ||
| 144 | }); | ||
| 145 | |||
| 146 | applyAttachEvents(manager, TARGET, [ | ||
| 147 | { | ||
| 148 | type: "output", | ||
| 149 | threadId: TARGET.threadId, | ||
| 150 | terminalId: TARGET.terminalId, | ||
| 151 | data: "🙂🙂", | ||
| 152 | }, | ||
| 153 | ]); | ||
| 154 | |||
| 155 | expect(manager.getSnapshot(TARGET).buffer).toBe("🙂"); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it("invalidates one environment without clearing others", () => { | ||
| 159 | const manager = createTerminalSessionManager({ | ||
| 160 | getRegistry: () => atomRegistry, | ||
| 161 | }); | ||
| 162 | const otherTarget = { | ||
| 163 | environmentId: EnvironmentId.make("env-remote"), | ||
| 164 | threadId: ThreadId.make("thread-1"), | ||
| 165 | terminalId: "term-1", | ||
| 166 | } as const; | ||
| 167 | |||
| 168 | for (const target of [TARGET, otherTarget]) { | ||
| 169 | applyAttachEvents(manager, target, [ | ||
| 170 | { | ||
| 171 | type: "output", | ||
| 172 | threadId: target.threadId, | ||
| 173 | terminalId: target.terminalId, | ||
| 174 | data: target.environmentId, | ||
| 175 | }, | ||
| 176 | ]); | ||
| 177 | } | ||
| 178 | |||
| 179 | manager.invalidateEnvironment(TARGET.environmentId); | ||
| 180 | |||
| 181 | expect(manager.getSnapshot(TARGET).buffer).toBe(""); | ||
| 182 | expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote"); | ||
| 183 | }); | ||
| 184 | |||
| 185 | it("lists known sessions for a thread ordered by terminal id (numeric-aware)", () => { | ||
| 186 | const manager = createTerminalSessionManager({ | ||
| 187 | getRegistry: () => atomRegistry, | ||
| 188 | }); | ||
| 189 | |||
| 190 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 191 | { | ||
| 192 | type: "snapshot", | ||
| 193 | terminals: [ | ||
| 194 | { | ||
| 195 | threadId: TARGET.threadId, | ||
| 196 | terminalId: "term-10", | ||
| 197 | cwd: "/repo", | ||
| 198 | worktreePath: null, | ||
| 199 | status: "running", | ||
| 200 | pid: 125, | ||
| 201 | exitCode: null, | ||
| 202 | exitSignal: null, | ||
| 203 | updatedAt: "2026-04-01T00:00:05.000Z", | ||
| 204 | hasRunningSubprocess: false, | ||
| 205 | label: "Terminal 10", | ||
| 206 | }, | ||
| 207 | { | ||
| 208 | threadId: TARGET.threadId, | ||
| 209 | terminalId: TARGET.terminalId, | ||
| 210 | cwd: "/repo", | ||
| 211 | worktreePath: null, | ||
| 212 | status: "running", | ||
| 213 | pid: 123, | ||
| 214 | exitCode: null, | ||
| 215 | exitSignal: null, | ||
| 216 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 217 | hasRunningSubprocess: false, | ||
| 218 | label: "Terminal 1", | ||
| 219 | }, | ||
| 220 | { | ||
| 221 | threadId: TARGET.threadId, | ||
| 222 | terminalId: "term-2", | ||
| 223 | cwd: "/repo", | ||
| 224 | worktreePath: null, | ||
| 225 | status: "running", | ||
| 226 | pid: 124, | ||
| 227 | exitCode: null, | ||
| 228 | exitSignal: null, | ||
| 229 | updatedAt: "2026-04-01T00:00:02.000Z", | ||
| 230 | hasRunningSubprocess: false, | ||
| 231 | label: "Terminal 2", | ||
| 232 | }, | ||
| 233 | ], | ||
| 234 | }, | ||
| 235 | ]); | ||
| 236 | |||
| 237 | expect( | ||
| 238 | manager | ||
| 239 | .listSessions({ | ||
| 240 | environmentId: TARGET.environmentId, | ||
| 241 | threadId: TARGET.threadId, | ||
| 242 | }) | ||
| 243 | .map((session) => session.target.terminalId), | ||
| 244 | ).toEqual(["term-1", "term-2", "term-10"]); | ||
| 245 | }); | ||
| 246 | |||
| 247 | it("drops known sessions when an environment is invalidated", () => { | ||
| 248 | const manager = createTerminalSessionManager({ | ||
| 249 | getRegistry: () => atomRegistry, | ||
| 250 | }); | ||
| 251 | |||
| 252 | applyAttachEvents(manager, TARGET, [ | ||
| 253 | { | ||
| 254 | type: "output", | ||
| 255 | threadId: TARGET.threadId, | ||
| 256 | terminalId: TARGET.terminalId, | ||
| 257 | data: "hello", | ||
| 258 | }, | ||
| 259 | ]); | ||
| 260 | |||
| 261 | manager.invalidateEnvironment(TARGET.environmentId); | ||
| 262 | |||
| 263 | expect( | ||
| 264 | manager.listSessions({ | ||
| 265 | environmentId: TARGET.environmentId, | ||
| 266 | threadId: TARGET.threadId, | ||
| 267 | }), | ||
| 268 | ).toEqual([]); | ||
| 269 | }); | ||
| 270 | |||
| 271 | it("removes closed sessions from the known-session index while keeping local closed state", () => { | ||
| 272 | const manager = createTerminalSessionManager({ | ||
| 273 | getRegistry: () => atomRegistry, | ||
| 274 | }); | ||
| 275 | |||
| 276 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 277 | { | ||
| 278 | type: "upsert", | ||
| 279 | terminal: { | ||
| 280 | threadId: TARGET.threadId, | ||
| 281 | terminalId: TARGET.terminalId, | ||
| 282 | cwd: "/repo", | ||
| 283 | worktreePath: null, | ||
| 284 | status: "running", | ||
| 285 | pid: 123, | ||
| 286 | exitCode: null, | ||
| 287 | exitSignal: null, | ||
| 288 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 289 | hasRunningSubprocess: false, | ||
| 290 | label: "Terminal 1", | ||
| 291 | }, | ||
| 292 | }, | ||
| 293 | ]); | ||
| 294 | applyAttachEvents(manager, TARGET, [ | ||
| 295 | { | ||
| 296 | type: "snapshot", | ||
| 297 | snapshot: BASE_SNAPSHOT, | ||
| 298 | }, | ||
| 299 | { | ||
| 300 | type: "closed", | ||
| 301 | threadId: TARGET.threadId, | ||
| 302 | terminalId: TARGET.terminalId, | ||
| 303 | }, | ||
| 304 | ]); | ||
| 305 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 306 | { | ||
| 307 | type: "remove", | ||
| 308 | threadId: TARGET.threadId, | ||
| 309 | terminalId: TARGET.terminalId, | ||
| 310 | }, | ||
| 311 | ]); | ||
| 312 | |||
| 313 | expect( | ||
| 314 | manager.listSessions({ | ||
| 315 | environmentId: TARGET.environmentId, | ||
| 316 | threadId: TARGET.threadId, | ||
| 317 | }), | ||
| 318 | ).toEqual([]); | ||
| 319 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 320 | buffer: "hello", | ||
| 321 | status: "closed", | ||
| 322 | summary: null, | ||
| 323 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 324 | }); | ||
| 325 | }); | ||
| 326 | |||
| 327 | it("clears locally retained closed state on reset", () => { | ||
| 328 | const manager = createTerminalSessionManager({ | ||
| 329 | getRegistry: () => atomRegistry, | ||
| 330 | }); | ||
| 331 | |||
| 332 | applyAttachEvents(manager, TARGET, [ | ||
| 333 | { | ||
| 334 | type: "snapshot", | ||
| 335 | snapshot: BASE_SNAPSHOT, | ||
| 336 | }, | ||
| 337 | { | ||
| 338 | type: "closed", | ||
| 339 | threadId: TARGET.threadId, | ||
| 340 | terminalId: TARGET.terminalId, | ||
| 341 | }, | ||
| 342 | ]); | ||
| 343 | |||
| 344 | manager.reset(); | ||
| 345 | |||
| 346 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 347 | summary: null, | ||
| 348 | buffer: "", | ||
| 349 | status: "closed", | ||
| 350 | error: null, | ||
| 351 | hasRunningSubprocess: false, | ||
| 352 | updatedAt: null, | ||
| 353 | version: 0, | ||
| 354 | }); | ||
| 355 | }); | ||
| 356 | |||
| 357 | it("syncs snapshots returned from open calls immediately", () => { | ||
| 358 | const manager = createTerminalSessionManager({ | ||
| 359 | getRegistry: () => atomRegistry, | ||
| 360 | }); | ||
| 361 | |||
| 362 | applyAttachEvents(manager, TARGET, [ | ||
| 363 | { | ||
| 364 | type: "snapshot", | ||
| 365 | snapshot: { | ||
| 366 | ...BASE_SNAPSHOT, | ||
| 367 | history: "prompt$ ", | ||
| 368 | updatedAt: "2026-04-01T00:00:03.000Z", | ||
| 369 | }, | ||
| 370 | }, | ||
| 371 | ]); | ||
| 372 | |||
| 373 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 374 | buffer: "prompt$ ", | ||
| 375 | status: "running", | ||
| 376 | updatedAt: "2026-04-01T00:00:03.000Z", | ||
| 377 | }); | ||
| 378 | }); | ||
| 379 | |||
| 380 | it("syncs authoritative metadata snapshots and removes missing environment terminals", () => { | ||
| 381 | const manager = createTerminalSessionManager({ | ||
| 382 | getRegistry: () => atomRegistry, | ||
| 383 | }); | ||
| 384 | |||
| 385 | applyAttachEvents(manager, TARGET, [ | ||
| 386 | { | ||
| 387 | type: "snapshot", | ||
| 388 | snapshot: BASE_SNAPSHOT, | ||
| 389 | }, | ||
| 390 | ]); | ||
| 391 | applyAttachEvents( | ||
| 392 | manager, | ||
| 393 | { | ||
| 394 | environmentId: TARGET.environmentId, | ||
| 395 | threadId: TARGET.threadId, | ||
| 396 | terminalId: "term-2", | ||
| 397 | }, | ||
| 398 | [ | ||
| 399 | { | ||
| 400 | type: "snapshot", | ||
| 401 | snapshot: { | ||
| 402 | ...BASE_SNAPSHOT, | ||
| 403 | terminalId: "term-2", | ||
| 404 | label: "Terminal 2", | ||
| 405 | updatedAt: "2026-04-01T00:00:02.000Z", | ||
| 406 | }, | ||
| 407 | }, | ||
| 408 | ], | ||
| 409 | ); | ||
| 410 | |||
| 411 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 412 | { | ||
| 413 | type: "snapshot", | ||
| 414 | terminals: [ | ||
| 415 | { | ||
| 416 | threadId: TARGET.threadId, | ||
| 417 | terminalId: "term-2", | ||
| 418 | cwd: "/repo", | ||
| 419 | worktreePath: null, | ||
| 420 | status: "running", | ||
| 421 | pid: 123, | ||
| 422 | exitCode: null, | ||
| 423 | exitSignal: null, | ||
| 424 | updatedAt: "2026-04-01T00:00:05.000Z", | ||
| 425 | hasRunningSubprocess: true, | ||
| 426 | label: "Terminal 2", | ||
| 427 | }, | ||
| 428 | ], | ||
| 429 | }, | ||
| 430 | ]); | ||
| 431 | |||
| 432 | expect( | ||
| 433 | manager.listSessions({ | ||
| 434 | environmentId: TARGET.environmentId, | ||
| 435 | threadId: TARGET.threadId, | ||
| 436 | }), | ||
| 437 | ).toMatchObject([ | ||
| 438 | { | ||
| 439 | target: { | ||
| 440 | environmentId: TARGET.environmentId, | ||
| 441 | threadId: TARGET.threadId, | ||
| 442 | terminalId: "term-2", | ||
| 443 | }, | ||
| 444 | state: { | ||
| 445 | summary: { | ||
| 446 | terminalId: "term-2", | ||
| 447 | cwd: "/repo", | ||
| 448 | }, | ||
| 449 | hasRunningSubprocess: true, | ||
| 450 | }, | ||
| 451 | }, | ||
| 452 | ]); | ||
| 453 | }); | ||
| 454 | |||
| 455 | it("updates listed session metadata when existing session activity changes", () => { | ||
| 456 | const manager = createTerminalSessionManager({ | ||
| 457 | getRegistry: () => atomRegistry, | ||
| 458 | }); | ||
| 459 | |||
| 460 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 461 | { | ||
| 462 | type: "upsert", | ||
| 463 | terminal: { | ||
| 464 | threadId: TARGET.threadId, | ||
| 465 | terminalId: TARGET.terminalId, | ||
| 466 | cwd: "/repo", | ||
| 467 | worktreePath: null, | ||
| 468 | status: "running", | ||
| 469 | pid: 123, | ||
| 470 | exitCode: null, | ||
| 471 | exitSignal: null, | ||
| 472 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 473 | hasRunningSubprocess: false, | ||
| 474 | label: "Terminal 1", | ||
| 475 | }, | ||
| 476 | }, | ||
| 477 | ]); | ||
| 478 | |||
| 479 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 480 | { | ||
| 481 | type: "upsert", | ||
| 482 | terminal: { | ||
| 483 | threadId: TARGET.threadId, | ||
| 484 | terminalId: TARGET.terminalId, | ||
| 485 | cwd: "/repo", | ||
| 486 | worktreePath: null, | ||
| 487 | status: "running", | ||
| 488 | pid: 123, | ||
| 489 | exitCode: null, | ||
| 490 | exitSignal: null, | ||
| 491 | updatedAt: "2026-04-01T00:00:05.000Z", | ||
| 492 | hasRunningSubprocess: true, | ||
| 493 | label: "Terminal 1", | ||
| 494 | }, | ||
| 495 | }, | ||
| 496 | ]); | ||
| 497 | |||
| 498 | expect( | ||
| 499 | manager.listSessions({ environmentId: TARGET.environmentId, threadId: TARGET.threadId }), | ||
| 500 | ).toMatchObject([ | ||
| 501 | { | ||
| 502 | state: { | ||
| 503 | hasRunningSubprocess: true, | ||
| 504 | }, | ||
| 505 | }, | ||
| 506 | ]); | ||
| 507 | }); | ||
| 508 | |||
| 509 | it("derives session atoms from structurally equal target objects", () => { | ||
| 510 | const manager = createTerminalSessionManager({ | ||
| 511 | getRegistry: () => atomRegistry, | ||
| 512 | }); | ||
| 513 | |||
| 514 | applyMetadataEvents(manager, TARGET.environmentId, [ | ||
| 515 | { | ||
| 516 | type: "upsert", | ||
| 517 | terminal: { | ||
| 518 | threadId: TARGET.threadId, | ||
| 519 | terminalId: TARGET.terminalId, | ||
| 520 | cwd: "/repo", | ||
| 521 | worktreePath: null, | ||
| 522 | status: "running", | ||
| 523 | pid: 123, | ||
| 524 | exitCode: null, | ||
| 525 | exitSignal: null, | ||
| 526 | updatedAt: BASE_SNAPSHOT.updatedAt, | ||
| 527 | hasRunningSubprocess: true, | ||
| 528 | label: "Terminal 1", | ||
| 529 | }, | ||
| 530 | }, | ||
| 531 | ]); | ||
| 532 | applyAttachEvents(manager, TARGET, [ | ||
| 533 | { | ||
| 534 | type: "snapshot", | ||
| 535 | snapshot: BASE_SNAPSHOT, | ||
| 536 | }, | ||
| 537 | ]); | ||
| 538 | |||
| 539 | const equalTarget = { ...TARGET }; | ||
| 540 | const filter = getKnownTerminalSessionListFilter({ | ||
| 541 | environmentId: TARGET.environmentId, | ||
| 542 | threadId: TARGET.threadId, | ||
| 543 | }); | ||
| 544 | expect(filter).not.toBeNull(); | ||
| 545 | if (filter === null) { | ||
| 546 | return; | ||
| 547 | } | ||
| 548 | |||
| 549 | expect(atomRegistry.get(terminalSessionStateAtom(equalTarget))).toMatchObject({ | ||
| 550 | buffer: BASE_SNAPSHOT.history, | ||
| 551 | hasRunningSubprocess: true, | ||
| 552 | }); | ||
| 553 | expect( | ||
| 554 | atomRegistry.get(knownTerminalSessionsAtom({ ...filter })).map((session) => session.target), | ||
| 555 | ).toEqual([TARGET]); | ||
| 556 | expect(atomRegistry.get(runningTerminalIdsAtom({ ...filter }))).toEqual([TARGET.terminalId]); | ||
| 557 | }); | ||
| 558 | }); | ||
packages/client-runtime/src/terminalSessionState.ts created+605| ... | @@ -0,0 +1,605 @@ | ||
| 1 | import type { | ||
| 2 | TerminalAttachStreamEvent, | ||
| 3 | TerminalMetadataStreamEvent, | ||
| 4 | TerminalSessionSnapshot, | ||
| 5 | TerminalSummary, | ||
| 6 | EnvironmentId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts"; | ||
| 9 | import * as Arr from "effect/Array"; | ||
| 10 | import { pipe } from "effect/Function"; | ||
| 11 | import * as Order from "effect/Order"; | ||
| 12 | import * as Result from "effect/Result"; | ||
| 13 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 14 | |||
| 15 | export interface TerminalSessionState { | ||
| 16 | readonly summary: TerminalSummary | null; | ||
| 17 | readonly buffer: string; | ||
| 18 | readonly status: TerminalSessionSnapshot["status"] | "closed"; | ||
| 19 | readonly error: string | null; | ||
| 20 | readonly hasRunningSubprocess: boolean; | ||
| 21 | readonly updatedAt: string | null; | ||
| 22 | readonly version: number; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface TerminalBufferState { | ||
| 26 | readonly buffer: string; | ||
| 27 | readonly status: TerminalSessionSnapshot["status"] | "closed"; | ||
| 28 | readonly error: string | null; | ||
| 29 | readonly updatedAt: string | null; | ||
| 30 | readonly version: number; | ||
| 31 | } | ||
| 32 | |||
| 33 | export interface TerminalSessionTarget { | ||
| 34 | readonly environmentId: EnvironmentId | null; | ||
| 35 | readonly threadId: ThreadId | null; | ||
| 36 | readonly terminalId: string | null; | ||
| 37 | } | ||
| 38 | |||
| 39 | export interface KnownTerminalSessionTarget { | ||
| 40 | readonly environmentId: EnvironmentId; | ||
| 41 | readonly threadId: ThreadId; | ||
| 42 | readonly terminalId: string; | ||
| 43 | } | ||
| 44 | |||
| 45 | export interface KnownTerminalSession { | ||
| 46 | readonly target: KnownTerminalSessionTarget; | ||
| 47 | readonly state: TerminalSessionState; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface KnownTerminalMetadata { | ||
| 51 | readonly target: KnownTerminalSessionTarget; | ||
| 52 | readonly summary: TerminalSummary; | ||
| 53 | } | ||
| 54 | |||
| 55 | export interface TerminalSessionListFilter { | ||
| 56 | readonly environmentId: EnvironmentId | null; | ||
| 57 | readonly threadId?: ThreadId | null; | ||
| 58 | readonly terminalId?: string | null; | ||
| 59 | } | ||
| 60 | |||
| 61 | export interface KnownTerminalSessionListFilter { | ||
| 62 | readonly environmentId: EnvironmentId; | ||
| 63 | readonly threadId: ThreadId | null; | ||
| 64 | readonly terminalId: string | null; | ||
| 65 | } | ||
| 66 | |||
| 67 | export interface TerminalSessionManagerConfig { | ||
| 68 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 69 | readonly maxBufferBytes?: number; | ||
| 70 | } | ||
| 71 | |||
| 72 | export interface TerminalMetadataClient { | ||
| 73 | readonly terminal: { | ||
| 74 | readonly onMetadata: ( | ||
| 75 | listener: (event: TerminalMetadataStreamEvent) => void, | ||
| 76 | options?: { readonly onResubscribe?: () => void }, | ||
| 77 | ) => () => void; | ||
| 78 | }; | ||
| 79 | } | ||
| 80 | |||
| 81 | export interface TerminalAttachClient { | ||
| 82 | readonly terminal: { | ||
| 83 | readonly attach: ( | ||
| 84 | input: TerminalAttachInput, | ||
| 85 | listener: (event: TerminalAttachStreamEvent) => void, | ||
| 86 | options?: { readonly onResubscribe?: () => void }, | ||
| 87 | ) => () => void; | ||
| 88 | }; | ||
| 89 | } | ||
| 90 | |||
| 91 | export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ | ||
| 92 | buffer: "", | ||
| 93 | status: "closed", | ||
| 94 | error: null, | ||
| 95 | updatedAt: null, | ||
| 96 | version: 0, | ||
| 97 | }); | ||
| 98 | |||
| 99 | export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({ | ||
| 100 | summary: null, | ||
| 101 | buffer: "", | ||
| 102 | status: "closed", | ||
| 103 | error: null, | ||
| 104 | hasRunningSubprocess: false, | ||
| 105 | updatedAt: null, | ||
| 106 | version: 0, | ||
| 107 | }); | ||
| 108 | |||
| 109 | const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<Array<KnownTerminalSession>>([]); | ||
| 110 | const EMPTY_TERMINAL_ID_LIST = Object.freeze<Array<string>>([]); | ||
| 111 | const DEFAULT_MAX_BUFFER_BYTES = 512 * 1024; | ||
| 112 | const knownTerminalMetadataEnvironmentIds = new Set<EnvironmentId>(); | ||
| 113 | const knownTerminalBufferTargets = new Map<string, KnownTerminalSessionTarget>(); | ||
| 114 | const textEncoder = new TextEncoder(); | ||
| 115 | const textDecoder = new TextDecoder(); | ||
| 116 | const terminalIdOrder = Order.make<string>( | ||
| 117 | (left, right) => left.localeCompare(right, undefined, { numeric: true }) as -1 | 0 | 1, | ||
| 118 | ); | ||
| 119 | const knownTerminalSessionOrder = Order.mapInput( | ||
| 120 | terminalIdOrder, | ||
| 121 | (session: KnownTerminalSession) => session.target.terminalId, | ||
| 122 | ); | ||
| 123 | |||
| 124 | export const terminalSessionMetadataAtom = Atom.family((environmentId: EnvironmentId) => { | ||
| 125 | knownTerminalMetadataEnvironmentIds.add(environmentId); | ||
| 126 | return Atom.make<Record<string, KnownTerminalMetadata>>({}).pipe( | ||
| 127 | Atom.keepAlive, | ||
| 128 | Atom.withLabel(`terminal-session:metadata:${environmentId}`), | ||
| 129 | ); | ||
| 130 | }); | ||
| 131 | |||
| 132 | export const terminalSessionBufferAtom = Atom.family((target: KnownTerminalSessionTarget) => { | ||
| 133 | const key = keyFromKnownTarget(target); | ||
| 134 | knownTerminalBufferTargets.set(key, target); | ||
| 135 | return Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe( | ||
| 136 | Atom.keepAlive, | ||
| 137 | Atom.withLabel(`terminal-session:buffer:${key}`), | ||
| 138 | ); | ||
| 139 | }); | ||
| 140 | |||
| 141 | export const EMPTY_TERMINAL_BUFFER_ATOM = Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe( | ||
| 142 | Atom.keepAlive, | ||
| 143 | Atom.withLabel("terminal-session:buffer:null"), | ||
| 144 | ); | ||
| 145 | |||
| 146 | export const EMPTY_TERMINAL_SESSION_ATOM = Atom.make(EMPTY_TERMINAL_SESSION_STATE).pipe( | ||
| 147 | Atom.keepAlive, | ||
| 148 | Atom.withLabel("terminal-session:state:null"), | ||
| 149 | ); | ||
| 150 | |||
| 151 | export const EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM = Atom.make(EMPTY_KNOWN_TERMINAL_SESSIONS).pipe( | ||
| 152 | Atom.keepAlive, | ||
| 153 | Atom.withLabel("terminal-session:known:null"), | ||
| 154 | ); | ||
| 155 | |||
| 156 | export const EMPTY_TERMINAL_ID_LIST_ATOM = Atom.make(EMPTY_TERMINAL_ID_LIST).pipe( | ||
| 157 | Atom.keepAlive, | ||
| 158 | Atom.withLabel("terminal-session:running-terminal-ids:null"), | ||
| 159 | ); | ||
| 160 | |||
| 161 | export function getKnownTerminalSessionTarget( | ||
| 162 | target: TerminalSessionTarget, | ||
| 163 | ): KnownTerminalSessionTarget | null { | ||
| 164 | if (target.environmentId === null || target.threadId === null || target.terminalId === null) { | ||
| 165 | return null; | ||
| 166 | } | ||
| 167 | |||
| 168 | return { | ||
| 169 | environmentId: target.environmentId, | ||
| 170 | threadId: target.threadId, | ||
| 171 | terminalId: target.terminalId, | ||
| 172 | }; | ||
| 173 | } | ||
| 174 | |||
| 175 | export function getKnownTerminalSessionListFilter( | ||
| 176 | filter: TerminalSessionListFilter, | ||
| 177 | ): KnownTerminalSessionListFilter | null { | ||
| 178 | if (filter.environmentId === null) { | ||
| 179 | return null; | ||
| 180 | } | ||
| 181 | |||
| 182 | return { | ||
| 183 | environmentId: filter.environmentId, | ||
| 184 | threadId: filter.threadId ?? null, | ||
| 185 | terminalId: filter.terminalId ?? null, | ||
| 186 | }; | ||
| 187 | } | ||
| 188 | |||
| 189 | function knownTargetFromSummary( | ||
| 190 | environmentId: EnvironmentId, | ||
| 191 | summary: TerminalSummary, | ||
| 192 | ): KnownTerminalSessionTarget { | ||
| 193 | return { | ||
| 194 | environmentId, | ||
| 195 | threadId: ThreadId.make(summary.threadId), | ||
| 196 | terminalId: summary.terminalId, | ||
| 197 | }; | ||
| 198 | } | ||
| 199 | |||
| 200 | function keyFromKnownTarget(target: KnownTerminalSessionTarget): string { | ||
| 201 | return `${target.environmentId}:${target.threadId}:${target.terminalId}`; | ||
| 202 | } | ||
| 203 | |||
| 204 | function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { | ||
| 205 | if (maxBufferBytes <= 0) { | ||
| 206 | return ""; | ||
| 207 | } | ||
| 208 | |||
| 209 | const encoded = textEncoder.encode(buffer); | ||
| 210 | if (encoded.byteLength <= maxBufferBytes) { | ||
| 211 | return buffer; | ||
| 212 | } | ||
| 213 | |||
| 214 | let start = encoded.byteLength - maxBufferBytes; | ||
| 215 | while (start < encoded.length) { | ||
| 216 | const byte = encoded[start]; | ||
| 217 | if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { | ||
| 218 | break; | ||
| 219 | } | ||
| 220 | start += 1; | ||
| 221 | } | ||
| 222 | |||
| 223 | return textDecoder.decode(encoded.subarray(start)); | ||
| 224 | } | ||
| 225 | |||
| 226 | function bufferFromSnapshot( | ||
| 227 | snapshot: TerminalSessionSnapshot, | ||
| 228 | maxBufferBytes: number, | ||
| 229 | ): TerminalBufferState { | ||
| 230 | return { | ||
| 231 | buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), | ||
| 232 | status: snapshot.status, | ||
| 233 | error: null, | ||
| 234 | updatedAt: snapshot.updatedAt, | ||
| 235 | version: 1, | ||
| 236 | }; | ||
| 237 | } | ||
| 238 | |||
| 239 | function latestTimestamp(left: string | null, right: string | null): string | null { | ||
| 240 | if (left === null) return right; | ||
| 241 | if (right === null) return left; | ||
| 242 | return Date.parse(left) >= Date.parse(right) ? left : right; | ||
| 243 | } | ||
| 244 | |||
| 245 | function combineSessionState( | ||
| 246 | summary: TerminalSummary | null, | ||
| 247 | buffer: TerminalBufferState, | ||
| 248 | ): TerminalSessionState { | ||
| 249 | return { | ||
| 250 | summary, | ||
| 251 | buffer: buffer.buffer, | ||
| 252 | status: summary?.status ?? buffer.status, | ||
| 253 | error: buffer.error, | ||
| 254 | hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, | ||
| 255 | updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), | ||
| 256 | version: buffer.version, | ||
| 257 | }; | ||
| 258 | } | ||
| 259 | |||
| 260 | function listKnownSessionsFromMetadata( | ||
| 261 | metadata: Record<string, KnownTerminalMetadata>, | ||
| 262 | getBuffer: (target: KnownTerminalSessionTarget) => TerminalBufferState, | ||
| 263 | filter?: Partial<KnownTerminalSessionTarget>, | ||
| 264 | ): ReadonlyArray<KnownTerminalSession> { | ||
| 265 | return pipe( | ||
| 266 | Object.values(metadata), | ||
| 267 | Arr.filterMap(({ target, summary }) => { | ||
| 268 | if (filter?.environmentId && target.environmentId !== filter.environmentId) { | ||
| 269 | return Result.failVoid; | ||
| 270 | } | ||
| 271 | if (filter?.threadId && target.threadId !== filter.threadId) { | ||
| 272 | return Result.failVoid; | ||
| 273 | } | ||
| 274 | if (filter?.terminalId && target.terminalId !== filter.terminalId) { | ||
| 275 | return Result.failVoid; | ||
| 276 | } | ||
| 277 | return Result.succeed({ | ||
| 278 | target, | ||
| 279 | state: combineSessionState(summary, getBuffer(target)), | ||
| 280 | }); | ||
| 281 | }), | ||
| 282 | Arr.sort(knownTerminalSessionOrder), | ||
| 283 | ); | ||
| 284 | } | ||
| 285 | |||
| 286 | export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) => | ||
| 287 | Atom.make((get) => { | ||
| 288 | const targetKey = keyFromKnownTarget(target); | ||
| 289 | return combineSessionState( | ||
| 290 | get(terminalSessionMetadataAtom(target.environmentId))[targetKey]?.summary ?? null, | ||
| 291 | get(terminalSessionBufferAtom(target)), | ||
| 292 | ); | ||
| 293 | }).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:state:${keyFromKnownTarget(target)}`)), | ||
| 294 | ); | ||
| 295 | |||
| 296 | export const knownTerminalSessionsAtom = Atom.family((filter: KnownTerminalSessionListFilter) => | ||
| 297 | Atom.make((get) => | ||
| 298 | listKnownSessionsFromMetadata( | ||
| 299 | get(terminalSessionMetadataAtom(filter.environmentId)), | ||
| 300 | (target) => get(terminalSessionBufferAtom(target)), | ||
| 301 | { | ||
| 302 | environmentId: filter.environmentId, | ||
| 303 | ...(filter.threadId !== null ? { threadId: filter.threadId } : {}), | ||
| 304 | ...(filter.terminalId !== null ? { terminalId: filter.terminalId } : {}), | ||
| 305 | }, | ||
| 306 | ), | ||
| 307 | ).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:known:${JSON.stringify(filter)}`)), | ||
| 308 | ); | ||
| 309 | |||
| 310 | export const runningTerminalIdsAtom = Atom.family((filter: KnownTerminalSessionListFilter) => | ||
| 311 | Atom.make((get) => { | ||
| 312 | return pipe( | ||
| 313 | Object.values(get(terminalSessionMetadataAtom(filter.environmentId))), | ||
| 314 | Arr.filterMap((entry) => | ||
| 315 | entry.target.environmentId === filter.environmentId && | ||
| 316 | (filter.threadId === null || entry.target.threadId === filter.threadId) && | ||
| 317 | (filter.terminalId === null || entry.target.terminalId === filter.terminalId) && | ||
| 318 | entry.summary.hasRunningSubprocess | ||
| 319 | ? Result.succeed(entry.target.terminalId) | ||
| 320 | : Result.failVoid, | ||
| 321 | ), | ||
| 322 | Arr.sort(Order.String), | ||
| 323 | ); | ||
| 324 | }).pipe( | ||
| 325 | Atom.keepAlive, | ||
| 326 | Atom.withLabel(`terminal-session:running-terminal-ids:${JSON.stringify(filter)}`), | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | |||
| 330 | export function createTerminalSessionManager(config: TerminalSessionManagerConfig) { | ||
| 331 | const maxBufferBytes = config.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; | ||
| 332 | |||
| 333 | function getMetadata(environmentId: EnvironmentId): Record<string, KnownTerminalMetadata> { | ||
| 334 | return config.getRegistry().get(terminalSessionMetadataAtom(environmentId)); | ||
| 335 | } | ||
| 336 | |||
| 337 | function setMetadata( | ||
| 338 | environmentId: EnvironmentId, | ||
| 339 | next: Record<string, KnownTerminalMetadata>, | ||
| 340 | ): void { | ||
| 341 | config.getRegistry().set(terminalSessionMetadataAtom(environmentId), next); | ||
| 342 | } | ||
| 343 | |||
| 344 | function getBuffer(target: KnownTerminalSessionTarget): TerminalBufferState { | ||
| 345 | return config.getRegistry().get(terminalSessionBufferAtom(target)); | ||
| 346 | } | ||
| 347 | |||
| 348 | function setBuffer(target: KnownTerminalSessionTarget, next: TerminalBufferState): void { | ||
| 349 | config.getRegistry().set(terminalSessionBufferAtom(target), next); | ||
| 350 | } | ||
| 351 | |||
| 352 | function getSnapshot(target: TerminalSessionTarget): TerminalSessionState { | ||
| 353 | const knownTarget = getKnownTerminalSessionTarget(target); | ||
| 354 | if (knownTarget === null) { | ||
| 355 | return EMPTY_TERMINAL_SESSION_STATE; | ||
| 356 | } | ||
| 357 | |||
| 358 | return combineSessionState( | ||
| 359 | getMetadata(knownTarget.environmentId)[keyFromKnownTarget(knownTarget)]?.summary ?? null, | ||
| 360 | getBuffer(knownTarget), | ||
| 361 | ); | ||
| 362 | } | ||
| 363 | |||
| 364 | function syncSnapshot( | ||
| 365 | target: Pick<TerminalSessionTarget, "environmentId">, | ||
| 366 | snapshot: TerminalSessionSnapshot, | ||
| 367 | ): void { | ||
| 368 | const knownTarget = getKnownTerminalSessionTarget({ | ||
| 369 | environmentId: target.environmentId, | ||
| 370 | threadId: ThreadId.make(snapshot.threadId), | ||
| 371 | terminalId: snapshot.terminalId, | ||
| 372 | }); | ||
| 373 | if (knownTarget === null) { | ||
| 374 | return; | ||
| 375 | } | ||
| 376 | |||
| 377 | setBuffer(knownTarget, bufferFromSnapshot(snapshot, maxBufferBytes)); | ||
| 378 | } | ||
| 379 | |||
| 380 | function applyMetadataEvent( | ||
| 381 | target: Pick<TerminalSessionTarget, "environmentId">, | ||
| 382 | event: TerminalMetadataStreamEvent, | ||
| 383 | ): void { | ||
| 384 | const environmentId = target.environmentId; | ||
| 385 | if (environmentId === null) { | ||
| 386 | return; | ||
| 387 | } | ||
| 388 | |||
| 389 | if (event.type === "snapshot") { | ||
| 390 | const retainedKeys = new Set<string>(); | ||
| 391 | const next = { ...getMetadata(environmentId) }; | ||
| 392 | |||
| 393 | for (const terminal of event.terminals) { | ||
| 394 | const knownTarget = knownTargetFromSummary(environmentId, terminal); | ||
| 395 | const targetKey = keyFromKnownTarget(knownTarget); | ||
| 396 | retainedKeys.add(targetKey); | ||
| 397 | next[targetKey] = { | ||
| 398 | target: knownTarget, | ||
| 399 | summary: terminal, | ||
| 400 | }; | ||
| 401 | } | ||
| 402 | |||
| 403 | for (const key of Object.keys(next)) { | ||
| 404 | if (!retainedKeys.has(key)) { | ||
| 405 | delete next[key]; | ||
| 406 | } | ||
| 407 | } | ||
| 408 | |||
| 409 | setMetadata(environmentId, next); | ||
| 410 | return; | ||
| 411 | } | ||
| 412 | |||
| 413 | if (event.type === "upsert") { | ||
| 414 | const knownTarget = knownTargetFromSummary(environmentId, event.terminal); | ||
| 415 | const targetKey = keyFromKnownTarget(knownTarget); | ||
| 416 | setMetadata(environmentId, { | ||
| 417 | ...getMetadata(environmentId), | ||
| 418 | [targetKey]: { | ||
| 419 | target: knownTarget, | ||
| 420 | summary: event.terminal, | ||
| 421 | }, | ||
| 422 | }); | ||
| 423 | return; | ||
| 424 | } | ||
| 425 | |||
| 426 | const knownTarget = getKnownTerminalSessionTarget({ | ||
| 427 | environmentId, | ||
| 428 | threadId: ThreadId.make(event.threadId), | ||
| 429 | terminalId: event.terminalId, | ||
| 430 | }); | ||
| 431 | if (knownTarget === null) { | ||
| 432 | return; | ||
| 433 | } | ||
| 434 | |||
| 435 | const next = { ...getMetadata(environmentId) }; | ||
| 436 | delete next[keyFromKnownTarget(knownTarget)]; | ||
| 437 | setMetadata(environmentId, next); | ||
| 438 | } | ||
| 439 | |||
| 440 | function applyAttachEvent( | ||
| 441 | target: Pick<TerminalSessionTarget, "environmentId">, | ||
| 442 | event: TerminalAttachStreamEvent, | ||
| 443 | ): void { | ||
| 444 | if (event.type === "snapshot") { | ||
| 445 | syncSnapshot(target, event.snapshot); | ||
| 446 | return; | ||
| 447 | } | ||
| 448 | |||
| 449 | const knownTarget = getKnownTerminalSessionTarget({ | ||
| 450 | environmentId: target.environmentId, | ||
| 451 | threadId: ThreadId.make(event.threadId), | ||
| 452 | terminalId: event.terminalId, | ||
| 453 | }); | ||
| 454 | if (knownTarget === null) { | ||
| 455 | return; | ||
| 456 | } | ||
| 457 | |||
| 458 | const current = getBuffer(knownTarget); | ||
| 459 | switch (event.type) { | ||
| 460 | case "restarted": | ||
| 461 | setBuffer(knownTarget, bufferFromSnapshot(event.snapshot, maxBufferBytes)); | ||
| 462 | return; | ||
| 463 | case "output": | ||
| 464 | setBuffer(knownTarget, { | ||
| 465 | ...current, | ||
| 466 | buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), | ||
| 467 | status: current.status === "closed" ? "running" : current.status, | ||
| 468 | error: null, | ||
| 469 | version: current.version + 1, | ||
| 470 | }); | ||
| 471 | return; | ||
| 472 | case "cleared": | ||
| 473 | setBuffer(knownTarget, { | ||
| 474 | ...current, | ||
| 475 | buffer: "", | ||
| 476 | error: null, | ||
| 477 | version: current.version + 1, | ||
| 478 | }); | ||
| 479 | return; | ||
| 480 | case "exited": | ||
| 481 | setBuffer(knownTarget, { | ||
| 482 | ...current, | ||
| 483 | status: "exited", | ||
| 484 | error: null, | ||
| 485 | version: current.version + 1, | ||
| 486 | }); | ||
| 487 | return; | ||
| 488 | case "closed": | ||
| 489 | setBuffer(knownTarget, { | ||
| 490 | ...current, | ||
| 491 | status: "closed", | ||
| 492 | error: null, | ||
| 493 | version: current.version + 1, | ||
| 494 | }); | ||
| 495 | return; | ||
| 496 | case "error": | ||
| 497 | setBuffer(knownTarget, { | ||
| 498 | ...current, | ||
| 499 | status: "error", | ||
| 500 | error: event.message, | ||
| 501 | version: current.version + 1, | ||
| 502 | }); | ||
| 503 | return; | ||
| 504 | case "activity": | ||
| 505 | return; | ||
| 506 | } | ||
| 507 | } | ||
| 508 | |||
| 509 | function invalidate(target?: TerminalSessionTarget): void { | ||
| 510 | if (target) { | ||
| 511 | const knownTarget = getKnownTerminalSessionTarget(target); | ||
| 512 | if (knownTarget !== null) { | ||
| 513 | const targetKey = keyFromKnownTarget(knownTarget); | ||
| 514 | const next = { ...getMetadata(knownTarget.environmentId) }; | ||
| 515 | delete next[targetKey]; | ||
| 516 | setMetadata(knownTarget.environmentId, next); | ||
| 517 | setBuffer(knownTarget, EMPTY_TERMINAL_BUFFER_STATE); | ||
| 518 | } | ||
| 519 | return; | ||
| 520 | } | ||
| 521 | |||
| 522 | for (const environmentId of knownTerminalMetadataEnvironmentIds) { | ||
| 523 | setMetadata(environmentId, {}); | ||
| 524 | } | ||
| 525 | knownTerminalMetadataEnvironmentIds.clear(); | ||
| 526 | for (const target of knownTerminalBufferTargets.values()) { | ||
| 527 | setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE); | ||
| 528 | } | ||
| 529 | knownTerminalBufferTargets.clear(); | ||
| 530 | } | ||
| 531 | |||
| 532 | function invalidateEnvironment(environmentId: EnvironmentId): void { | ||
| 533 | setMetadata(environmentId, {}); | ||
| 534 | knownTerminalMetadataEnvironmentIds.delete(environmentId); | ||
| 535 | |||
| 536 | const prefix = `${environmentId}:`; | ||
| 537 | for (const [key, target] of knownTerminalBufferTargets) { | ||
| 538 | if (key.startsWith(prefix)) { | ||
| 539 | setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE); | ||
| 540 | } | ||
| 541 | } | ||
| 542 | } | ||
| 543 | |||
| 544 | function reset(): void { | ||
| 545 | invalidate(); | ||
| 546 | } | ||
| 547 | |||
| 548 | function listSessions( | ||
| 549 | filter?: Partial<KnownTerminalSessionTarget>, | ||
| 550 | ): ReadonlyArray<KnownTerminalSession> { | ||
| 551 | if (filter?.environmentId) { | ||
| 552 | return listKnownSessionsFromMetadata(getMetadata(filter.environmentId), getBuffer, filter); | ||
| 553 | } | ||
| 554 | |||
| 555 | return pipe( | ||
| 556 | knownTerminalMetadataEnvironmentIds, | ||
| 557 | Arr.fromIterable, | ||
| 558 | Arr.flatMap((environmentId) => | ||
| 559 | listKnownSessionsFromMetadata(getMetadata(environmentId), getBuffer, filter), | ||
| 560 | ), | ||
| 561 | ); | ||
| 562 | } | ||
| 563 | |||
| 564 | function subscribeMetadata(input: { | ||
| 565 | readonly environmentId: EnvironmentId; | ||
| 566 | readonly client: TerminalMetadataClient; | ||
| 567 | readonly options?: { readonly onResubscribe?: () => void }; | ||
| 568 | }): () => void { | ||
| 569 | return input.client.terminal.onMetadata( | ||
| 570 | (event) => applyMetadataEvent({ environmentId: input.environmentId }, event), | ||
| 571 | input.options, | ||
| 572 | ); | ||
| 573 | } | ||
| 574 | |||
| 575 | function attach(input: { | ||
| 576 | readonly environmentId: EnvironmentId; | ||
| 577 | readonly client: TerminalAttachClient; | ||
| 578 | readonly terminal: TerminalAttachInput; | ||
| 579 | readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; | ||
| 580 | readonly onEvent?: (event: TerminalAttachStreamEvent) => void; | ||
| 581 | readonly options?: { readonly onResubscribe?: () => void }; | ||
| 582 | }): () => void { | ||
| 583 | return input.client.terminal.attach( | ||
| 584 | input.terminal, | ||
| 585 | (event) => { | ||
| 586 | applyAttachEvent({ environmentId: input.environmentId }, event); | ||
| 587 | input.onEvent?.(event); | ||
| 588 | if (event.type === "snapshot") { | ||
| 589 | input.onSnapshot?.(event.snapshot); | ||
| 590 | } | ||
| 591 | }, | ||
| 592 | input.options, | ||
| 593 | ); | ||
| 594 | } | ||
| 595 | |||
| 596 | return { | ||
| 597 | attach, | ||
| 598 | getSnapshot, | ||
| 599 | invalidate, | ||
| 600 | invalidateEnvironment, | ||
| 601 | listSessions, | ||
| 602 | subscribeMetadata, | ||
| 603 | reset, | ||
| 604 | }; | ||
| 605 | } | ||
packages/client-runtime/src/threadDetailReducer.test.ts created+568| ... | @@ -0,0 +1,568 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | CheckpointRef, | ||
| 5 | EventId, | ||
| 6 | MessageId, | ||
| 7 | ProjectId, | ||
| 8 | ProviderInstanceId, | ||
| 9 | ThreadId, | ||
| 10 | TurnId, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import type { OrchestrationThread } from "@t3tools/contracts"; | ||
| 13 | |||
| 14 | import { applyThreadDetailEvent } from "./threadDetailReducer.ts"; | ||
| 15 | |||
| 16 | const baseEventFields = { | ||
| 17 | eventId: EventId.make("event-1"), | ||
| 18 | commandId: null, | ||
| 19 | causationEventId: null, | ||
| 20 | correlationId: null, | ||
| 21 | metadata: {}, | ||
| 22 | } as const; | ||
| 23 | |||
| 24 | const baseThread: OrchestrationThread = { | ||
| 25 | id: ThreadId.make("thread-1"), | ||
| 26 | projectId: ProjectId.make("project-1"), | ||
| 27 | title: "Test Thread", | ||
| 28 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 29 | runtimeMode: "full-access", | ||
| 30 | interactionMode: "default", | ||
| 31 | branch: null, | ||
| 32 | worktreePath: null, | ||
| 33 | latestTurn: null, | ||
| 34 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 35 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 36 | archivedAt: null, | ||
| 37 | deletedAt: null, | ||
| 38 | messages: [], | ||
| 39 | proposedPlans: [], | ||
| 40 | activities: [], | ||
| 41 | checkpoints: [], | ||
| 42 | session: null, | ||
| 43 | }; | ||
| 44 | |||
| 45 | describe("applyThreadDetailEvent", () => { | ||
| 46 | describe("project events", () => { | ||
| 47 | it("returns unchanged for project.created", () => { | ||
| 48 | const result = applyThreadDetailEvent(baseThread, { | ||
| 49 | ...baseEventFields, | ||
| 50 | sequence: 1, | ||
| 51 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 52 | aggregateKind: "project", | ||
| 53 | aggregateId: ProjectId.make("project-1"), | ||
| 54 | type: "project.created", | ||
| 55 | payload: { | ||
| 56 | projectId: ProjectId.make("project-1"), | ||
| 57 | title: "T3 Code", | ||
| 58 | workspaceRoot: "/repo", | ||
| 59 | repositoryIdentity: null, | ||
| 60 | defaultModelSelection: null, | ||
| 61 | scripts: [], | ||
| 62 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 63 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 64 | deletedAt: null, | ||
| 65 | }, | ||
| 66 | } as any); | ||
| 67 | expect(result.kind).toBe("unchanged"); | ||
| 68 | }); | ||
| 69 | }); | ||
| 70 | |||
| 71 | describe("thread.created", () => { | ||
| 72 | it("creates a fresh thread", () => { | ||
| 73 | const result = applyThreadDetailEvent(baseThread, { | ||
| 74 | ...baseEventFields, | ||
| 75 | sequence: 1, | ||
| 76 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 77 | aggregateKind: "thread", | ||
| 78 | aggregateId: ThreadId.make("thread-2"), | ||
| 79 | type: "thread.created", | ||
| 80 | payload: { | ||
| 81 | threadId: ThreadId.make("thread-2"), | ||
| 82 | projectId: ProjectId.make("project-1"), | ||
| 83 | title: "New Thread", | ||
| 84 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 85 | runtimeMode: "full-access", | ||
| 86 | interactionMode: "default", | ||
| 87 | branch: "main", | ||
| 88 | worktreePath: null, | ||
| 89 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 90 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 91 | }, | ||
| 92 | }); | ||
| 93 | |||
| 94 | expect(result.kind).toBe("updated"); | ||
| 95 | if (result.kind === "updated") { | ||
| 96 | expect(result.thread.id).toBe("thread-2"); | ||
| 97 | expect(result.thread.title).toBe("New Thread"); | ||
| 98 | expect(result.thread.branch).toBe("main"); | ||
| 99 | expect(result.thread.messages).toEqual([]); | ||
| 100 | expect(result.thread.session).toBeNull(); | ||
| 101 | } | ||
| 102 | }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | describe("thread.deleted", () => { | ||
| 106 | it("returns deleted signal", () => { | ||
| 107 | const result = applyThreadDetailEvent(baseThread, { | ||
| 108 | ...baseEventFields, | ||
| 109 | sequence: 2, | ||
| 110 | occurredAt: "2026-04-01T02:00:00.000Z", | ||
| 111 | aggregateKind: "thread", | ||
| 112 | aggregateId: ThreadId.make("thread-1"), | ||
| 113 | type: "thread.deleted", | ||
| 114 | payload: { | ||
| 115 | threadId: ThreadId.make("thread-1"), | ||
| 116 | deletedAt: "2026-04-01T02:00:00.000Z", | ||
| 117 | }, | ||
| 118 | }); | ||
| 119 | expect(result.kind).toBe("deleted"); | ||
| 120 | }); | ||
| 121 | }); | ||
| 122 | |||
| 123 | describe("thread.archived / thread.unarchived", () => { | ||
| 124 | it("sets archivedAt", () => { | ||
| 125 | const result = applyThreadDetailEvent(baseThread, { | ||
| 126 | ...baseEventFields, | ||
| 127 | sequence: 3, | ||
| 128 | occurredAt: "2026-04-01T03:00:00.000Z", | ||
| 129 | aggregateKind: "thread", | ||
| 130 | aggregateId: ThreadId.make("thread-1"), | ||
| 131 | type: "thread.archived", | ||
| 132 | payload: { | ||
| 133 | threadId: ThreadId.make("thread-1"), | ||
| 134 | archivedAt: "2026-04-01T03:00:00.000Z", | ||
| 135 | updatedAt: "2026-04-01T03:00:00.000Z", | ||
| 136 | }, | ||
| 137 | }); | ||
| 138 | |||
| 139 | expect(result.kind).toBe("updated"); | ||
| 140 | if (result.kind === "updated") { | ||
| 141 | expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z"); | ||
| 142 | } | ||
| 143 | }); | ||
| 144 | |||
| 145 | it("clears archivedAt", () => { | ||
| 146 | const archivedThread = { ...baseThread, archivedAt: "2026-04-01T03:00:00.000Z" }; | ||
| 147 | const result = applyThreadDetailEvent(archivedThread, { | ||
| 148 | ...baseEventFields, | ||
| 149 | sequence: 4, | ||
| 150 | occurredAt: "2026-04-01T04:00:00.000Z", | ||
| 151 | aggregateKind: "thread", | ||
| 152 | aggregateId: ThreadId.make("thread-1"), | ||
| 153 | type: "thread.unarchived", | ||
| 154 | payload: { | ||
| 155 | threadId: ThreadId.make("thread-1"), | ||
| 156 | updatedAt: "2026-04-01T04:00:00.000Z", | ||
| 157 | }, | ||
| 158 | }); | ||
| 159 | |||
| 160 | expect(result.kind).toBe("updated"); | ||
| 161 | if (result.kind === "updated") { | ||
| 162 | expect(result.thread.archivedAt).toBeNull(); | ||
| 163 | } | ||
| 164 | }); | ||
| 165 | }); | ||
| 166 | |||
| 167 | describe("thread.meta-updated", () => { | ||
| 168 | it("patches title and branch", () => { | ||
| 169 | const result = applyThreadDetailEvent(baseThread, { | ||
| 170 | ...baseEventFields, | ||
| 171 | sequence: 5, | ||
| 172 | occurredAt: "2026-04-01T05:00:00.000Z", | ||
| 173 | aggregateKind: "thread", | ||
| 174 | aggregateId: ThreadId.make("thread-1"), | ||
| 175 | type: "thread.meta-updated", | ||
| 176 | payload: { | ||
| 177 | threadId: ThreadId.make("thread-1"), | ||
| 178 | title: "Updated Title", | ||
| 179 | branch: "feature/demo", | ||
| 180 | updatedAt: "2026-04-01T05:00:00.000Z", | ||
| 181 | }, | ||
| 182 | }); | ||
| 183 | |||
| 184 | expect(result.kind).toBe("updated"); | ||
| 185 | if (result.kind === "updated") { | ||
| 186 | expect(result.thread.title).toBe("Updated Title"); | ||
| 187 | expect(result.thread.branch).toBe("feature/demo"); | ||
| 188 | // Model selection should be unchanged since it wasn't in the payload | ||
| 189 | expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); | ||
| 190 | } | ||
| 191 | }); | ||
| 192 | }); | ||
| 193 | |||
| 194 | describe("thread.message-sent", () => { | ||
| 195 | it("appends a new message", () => { | ||
| 196 | const result = applyThreadDetailEvent(baseThread, { | ||
| 197 | ...baseEventFields, | ||
| 198 | sequence: 6, | ||
| 199 | occurredAt: "2026-04-01T06:00:00.000Z", | ||
| 200 | aggregateKind: "thread", | ||
| 201 | aggregateId: ThreadId.make("thread-1"), | ||
| 202 | type: "thread.message-sent", | ||
| 203 | payload: { | ||
| 204 | threadId: ThreadId.make("thread-1"), | ||
| 205 | messageId: MessageId.make("msg-1"), | ||
| 206 | role: "user", | ||
| 207 | text: "Hello, world!", | ||
| 208 | turnId: null, | ||
| 209 | streaming: false, | ||
| 210 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 211 | updatedAt: "2026-04-01T06:00:00.000Z", | ||
| 212 | }, | ||
| 213 | }); | ||
| 214 | |||
| 215 | expect(result.kind).toBe("updated"); | ||
| 216 | if (result.kind === "updated") { | ||
| 217 | expect(result.thread.messages).toHaveLength(1); | ||
| 218 | expect(result.thread.messages[0]?.text).toBe("Hello, world!"); | ||
| 219 | } | ||
| 220 | }); | ||
| 221 | |||
| 222 | it("appends text for streaming messages", () => { | ||
| 223 | const threadWithMessage: OrchestrationThread = { | ||
| 224 | ...baseThread, | ||
| 225 | messages: [ | ||
| 226 | { | ||
| 227 | id: MessageId.make("msg-2"), | ||
| 228 | role: "assistant", | ||
| 229 | text: "Hello", | ||
| 230 | turnId: TurnId.make("turn-1"), | ||
| 231 | streaming: true, | ||
| 232 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 233 | updatedAt: "2026-04-01T06:00:00.000Z", | ||
| 234 | }, | ||
| 235 | ], | ||
| 236 | }; | ||
| 237 | |||
| 238 | const result = applyThreadDetailEvent(threadWithMessage, { | ||
| 239 | ...baseEventFields, | ||
| 240 | sequence: 7, | ||
| 241 | occurredAt: "2026-04-01T06:01:00.000Z", | ||
| 242 | aggregateKind: "thread", | ||
| 243 | aggregateId: ThreadId.make("thread-1"), | ||
| 244 | type: "thread.message-sent", | ||
| 245 | payload: { | ||
| 246 | threadId: ThreadId.make("thread-1"), | ||
| 247 | messageId: MessageId.make("msg-2"), | ||
| 248 | role: "assistant", | ||
| 249 | text: ", world!", | ||
| 250 | turnId: TurnId.make("turn-1"), | ||
| 251 | streaming: true, | ||
| 252 | createdAt: "2026-04-01T06:00:00.000Z", | ||
| 253 | updatedAt: "2026-04-01T06:01:00.000Z", | ||
| 254 | }, | ||
| 255 | }); | ||
| 256 | |||
| 257 | expect(result.kind).toBe("updated"); | ||
| 258 | if (result.kind === "updated") { | ||
| 259 | expect(result.thread.messages).toHaveLength(1); | ||
| 260 | expect(result.thread.messages[0]?.text).toBe("Hello, world!"); | ||
| 261 | } | ||
| 262 | }); | ||
| 263 | |||
| 264 | it("updates latestTurn for assistant messages with a turn", () => { | ||
| 265 | const result = applyThreadDetailEvent(baseThread, { | ||
| 266 | ...baseEventFields, | ||
| 267 | sequence: 8, | ||
| 268 | occurredAt: "2026-04-01T07:00:00.000Z", | ||
| 269 | aggregateKind: "thread", | ||
| 270 | aggregateId: ThreadId.make("thread-1"), | ||
| 271 | type: "thread.message-sent", | ||
| 272 | payload: { | ||
| 273 | threadId: ThreadId.make("thread-1"), | ||
| 274 | messageId: MessageId.make("msg-3"), | ||
| 275 | role: "assistant", | ||
| 276 | text: "Done.", | ||
| 277 | turnId: TurnId.make("turn-1"), | ||
| 278 | streaming: false, | ||
| 279 | createdAt: "2026-04-01T07:00:00.000Z", | ||
| 280 | updatedAt: "2026-04-01T07:00:00.000Z", | ||
| 281 | }, | ||
| 282 | }); | ||
| 283 | |||
| 284 | expect(result.kind).toBe("updated"); | ||
| 285 | if (result.kind === "updated") { | ||
| 286 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 287 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 288 | expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-3"); | ||
| 289 | } | ||
| 290 | }); | ||
| 291 | }); | ||
| 292 | |||
| 293 | describe("thread.session-set", () => { | ||
| 294 | it("updates session and latestTurn for a running session", () => { | ||
| 295 | const result = applyThreadDetailEvent(baseThread, { | ||
| 296 | ...baseEventFields, | ||
| 297 | sequence: 9, | ||
| 298 | occurredAt: "2026-04-01T08:00:00.000Z", | ||
| 299 | aggregateKind: "thread", | ||
| 300 | aggregateId: ThreadId.make("thread-1"), | ||
| 301 | type: "thread.session-set", | ||
| 302 | payload: { | ||
| 303 | threadId: ThreadId.make("thread-1"), | ||
| 304 | session: { | ||
| 305 | threadId: ThreadId.make("thread-1"), | ||
| 306 | status: "running", | ||
| 307 | providerName: "codex", | ||
| 308 | runtimeMode: "full-access", | ||
| 309 | activeTurnId: TurnId.make("turn-1"), | ||
| 310 | lastError: null, | ||
| 311 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 312 | }, | ||
| 313 | }, | ||
| 314 | }); | ||
| 315 | |||
| 316 | expect(result.kind).toBe("updated"); | ||
| 317 | if (result.kind === "updated") { | ||
| 318 | expect(result.thread.session?.status).toBe("running"); | ||
| 319 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 320 | expect(result.thread.latestTurn?.state).toBe("running"); | ||
| 321 | } | ||
| 322 | }); | ||
| 323 | }); | ||
| 324 | |||
| 325 | describe("thread.session-stop-requested", () => { | ||
| 326 | it("marks session as stopped", () => { | ||
| 327 | const threadWithSession: OrchestrationThread = { | ||
| 328 | ...baseThread, | ||
| 329 | session: { | ||
| 330 | threadId: ThreadId.make("thread-1"), | ||
| 331 | status: "running", | ||
| 332 | providerName: "codex", | ||
| 333 | runtimeMode: "full-access", | ||
| 334 | activeTurnId: TurnId.make("turn-1"), | ||
| 335 | lastError: null, | ||
| 336 | updatedAt: "2026-04-01T08:00:00.000Z", | ||
| 337 | }, | ||
| 338 | }; | ||
| 339 | |||
| 340 | const result = applyThreadDetailEvent(threadWithSession, { | ||
| 341 | ...baseEventFields, | ||
| 342 | sequence: 10, | ||
| 343 | occurredAt: "2026-04-01T09:00:00.000Z", | ||
| 344 | aggregateKind: "thread", | ||
| 345 | aggregateId: ThreadId.make("thread-1"), | ||
| 346 | type: "thread.session-stop-requested", | ||
| 347 | payload: { | ||
| 348 | threadId: ThreadId.make("thread-1"), | ||
| 349 | createdAt: "2026-04-01T09:00:00.000Z", | ||
| 350 | }, | ||
| 351 | }); | ||
| 352 | |||
| 353 | expect(result.kind).toBe("updated"); | ||
| 354 | if (result.kind === "updated") { | ||
| 355 | expect(result.thread.session?.status).toBe("stopped"); | ||
| 356 | expect(result.thread.session?.activeTurnId).toBeNull(); | ||
| 357 | } | ||
| 358 | }); | ||
| 359 | |||
| 360 | it("returns unchanged when no session exists", () => { | ||
| 361 | const result = applyThreadDetailEvent(baseThread, { | ||
| 362 | ...baseEventFields, | ||
| 363 | sequence: 10, | ||
| 364 | occurredAt: "2026-04-01T09:00:00.000Z", | ||
| 365 | aggregateKind: "thread", | ||
| 366 | aggregateId: ThreadId.make("thread-1"), | ||
| 367 | type: "thread.session-stop-requested", | ||
| 368 | payload: { | ||
| 369 | threadId: ThreadId.make("thread-1"), | ||
| 370 | createdAt: "2026-04-01T09:00:00.000Z", | ||
| 371 | }, | ||
| 372 | }); | ||
| 373 | expect(result.kind).toBe("unchanged"); | ||
| 374 | }); | ||
| 375 | }); | ||
| 376 | |||
| 377 | describe("thread.proposed-plan-upserted", () => { | ||
| 378 | it("adds a proposed plan", () => { | ||
| 379 | const result = applyThreadDetailEvent(baseThread, { | ||
| 380 | ...baseEventFields, | ||
| 381 | sequence: 11, | ||
| 382 | occurredAt: "2026-04-01T10:00:00.000Z", | ||
| 383 | aggregateKind: "thread", | ||
| 384 | aggregateId: ThreadId.make("thread-1"), | ||
| 385 | type: "thread.proposed-plan-upserted", | ||
| 386 | payload: { | ||
| 387 | threadId: ThreadId.make("thread-1"), | ||
| 388 | proposedPlan: { | ||
| 389 | id: "plan-1", | ||
| 390 | turnId: TurnId.make("turn-1"), | ||
| 391 | planMarkdown: "## Plan\n- Do stuff", | ||
| 392 | implementedAt: null, | ||
| 393 | implementationThreadId: null, | ||
| 394 | createdAt: "2026-04-01T10:00:00.000Z", | ||
| 395 | updatedAt: "2026-04-01T10:00:00.000Z", | ||
| 396 | }, | ||
| 397 | }, | ||
| 398 | }); | ||
| 399 | |||
| 400 | expect(result.kind).toBe("updated"); | ||
| 401 | if (result.kind === "updated") { | ||
| 402 | expect(result.thread.proposedPlans).toHaveLength(1); | ||
| 403 | expect(result.thread.proposedPlans[0]?.id).toBe("plan-1"); | ||
| 404 | } | ||
| 405 | }); | ||
| 406 | }); | ||
| 407 | |||
| 408 | describe("thread.activity-appended", () => { | ||
| 409 | it("adds an activity", () => { | ||
| 410 | const result = applyThreadDetailEvent(baseThread, { | ||
| 411 | ...baseEventFields, | ||
| 412 | sequence: 12, | ||
| 413 | occurredAt: "2026-04-01T11:00:00.000Z", | ||
| 414 | aggregateKind: "thread", | ||
| 415 | aggregateId: ThreadId.make("thread-1"), | ||
| 416 | type: "thread.activity-appended", | ||
| 417 | payload: { | ||
| 418 | threadId: ThreadId.make("thread-1"), | ||
| 419 | activity: { | ||
| 420 | id: EventId.make("activity-1"), | ||
| 421 | tone: "tool", | ||
| 422 | kind: "file-edit", | ||
| 423 | summary: "Edited src/index.ts", | ||
| 424 | payload: {}, | ||
| 425 | turnId: TurnId.make("turn-1"), | ||
| 426 | createdAt: "2026-04-01T11:00:00.000Z", | ||
| 427 | }, | ||
| 428 | }, | ||
| 429 | }); | ||
| 430 | |||
| 431 | expect(result.kind).toBe("updated"); | ||
| 432 | if (result.kind === "updated") { | ||
| 433 | expect(result.thread.activities).toHaveLength(1); | ||
| 434 | expect(result.thread.activities[0]?.kind).toBe("file-edit"); | ||
| 435 | } | ||
| 436 | }); | ||
| 437 | }); | ||
| 438 | |||
| 439 | describe("thread.turn-diff-completed", () => { | ||
| 440 | it("adds a checkpoint and updates latestTurn", () => { | ||
| 441 | const result = applyThreadDetailEvent(baseThread, { | ||
| 442 | ...baseEventFields, | ||
| 443 | sequence: 13, | ||
| 444 | occurredAt: "2026-04-01T12:00:00.000Z", | ||
| 445 | aggregateKind: "thread", | ||
| 446 | aggregateId: ThreadId.make("thread-1"), | ||
| 447 | type: "thread.turn-diff-completed", | ||
| 448 | payload: { | ||
| 449 | threadId: ThreadId.make("thread-1"), | ||
| 450 | turnId: TurnId.make("turn-1"), | ||
| 451 | checkpointTurnCount: 1, | ||
| 452 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 453 | status: "ready", | ||
| 454 | files: [], | ||
| 455 | assistantMessageId: MessageId.make("msg-3"), | ||
| 456 | completedAt: "2026-04-01T12:00:00.000Z", | ||
| 457 | }, | ||
| 458 | }); | ||
| 459 | |||
| 460 | expect(result.kind).toBe("updated"); | ||
| 461 | if (result.kind === "updated") { | ||
| 462 | expect(result.thread.checkpoints).toHaveLength(1); | ||
| 463 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 464 | expect(result.thread.latestTurn?.state).toBe("completed"); | ||
| 465 | } | ||
| 466 | }); | ||
| 467 | }); | ||
| 468 | |||
| 469 | describe("thread.reverted", () => { | ||
| 470 | it("filters entities to retained turns", () => { | ||
| 471 | const threadWithData: OrchestrationThread = { | ||
| 472 | ...baseThread, | ||
| 473 | messages: [ | ||
| 474 | { | ||
| 475 | id: MessageId.make("msg-1"), | ||
| 476 | role: "user", | ||
| 477 | text: "First", | ||
| 478 | turnId: null, | ||
| 479 | streaming: false, | ||
| 480 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 481 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 482 | }, | ||
| 483 | { | ||
| 484 | id: MessageId.make("msg-2"), | ||
| 485 | role: "assistant", | ||
| 486 | text: "Response 1", | ||
| 487 | turnId: TurnId.make("turn-1"), | ||
| 488 | streaming: false, | ||
| 489 | createdAt: "2026-04-01T02:00:00.000Z", | ||
| 490 | updatedAt: "2026-04-01T02:00:00.000Z", | ||
| 491 | }, | ||
| 492 | { | ||
| 493 | id: MessageId.make("msg-3"), | ||
| 494 | role: "assistant", | ||
| 495 | text: "Response 2", | ||
| 496 | turnId: TurnId.make("turn-2"), | ||
| 497 | streaming: false, | ||
| 498 | createdAt: "2026-04-01T03:00:00.000Z", | ||
| 499 | updatedAt: "2026-04-01T03:00:00.000Z", | ||
| 500 | }, | ||
| 501 | ], | ||
| 502 | checkpoints: [ | ||
| 503 | { | ||
| 504 | turnId: TurnId.make("turn-1"), | ||
| 505 | checkpointTurnCount: 1, | ||
| 506 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 507 | status: "ready", | ||
| 508 | files: [], | ||
| 509 | assistantMessageId: MessageId.make("msg-2"), | ||
| 510 | completedAt: "2026-04-01T02:00:00.000Z", | ||
| 511 | }, | ||
| 512 | { | ||
| 513 | turnId: TurnId.make("turn-2"), | ||
| 514 | checkpointTurnCount: 2, | ||
| 515 | checkpointRef: CheckpointRef.make("ref-2"), | ||
| 516 | status: "ready", | ||
| 517 | files: [], | ||
| 518 | assistantMessageId: MessageId.make("msg-3"), | ||
| 519 | completedAt: "2026-04-01T03:00:00.000Z", | ||
| 520 | }, | ||
| 521 | ], | ||
| 522 | }; | ||
| 523 | |||
| 524 | const result = applyThreadDetailEvent(threadWithData, { | ||
| 525 | ...baseEventFields, | ||
| 526 | sequence: 14, | ||
| 527 | occurredAt: "2026-04-01T04:00:00.000Z", | ||
| 528 | aggregateKind: "thread", | ||
| 529 | aggregateId: ThreadId.make("thread-1"), | ||
| 530 | type: "thread.reverted", | ||
| 531 | payload: { | ||
| 532 | threadId: ThreadId.make("thread-1"), | ||
| 533 | turnCount: 1, | ||
| 534 | }, | ||
| 535 | }); | ||
| 536 | |||
| 537 | expect(result.kind).toBe("updated"); | ||
| 538 | if (result.kind === "updated") { | ||
| 539 | // turn-2 checkpoint is filtered out (turnCount 2 > revert target 1) | ||
| 540 | expect(result.thread.checkpoints).toHaveLength(1); | ||
| 541 | expect(result.thread.checkpoints[0]?.turnId).toBe("turn-1"); | ||
| 542 | // msg-3 (turn-2) is filtered, msg-1 (no turn) and msg-2 (turn-1) remain | ||
| 543 | expect(result.thread.messages).toHaveLength(2); | ||
| 544 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 545 | } | ||
| 546 | }); | ||
| 547 | }); | ||
| 548 | |||
| 549 | describe("no-op events", () => { | ||
| 550 | it("returns unchanged for approval-response-requested", () => { | ||
| 551 | const result = applyThreadDetailEvent(baseThread, { | ||
| 552 | ...baseEventFields, | ||
| 553 | sequence: 15, | ||
| 554 | occurredAt: "2026-04-01T13:00:00.000Z", | ||
| 555 | aggregateKind: "thread", | ||
| 556 | aggregateId: ThreadId.make("thread-1"), | ||
| 557 | type: "thread.approval-response-requested", | ||
| 558 | payload: { | ||
| 559 | threadId: ThreadId.make("thread-1"), | ||
| 560 | requestId: "req-1", | ||
| 561 | decision: "approve", | ||
| 562 | createdAt: "2026-04-01T13:00:00.000Z", | ||
| 563 | }, | ||
| 564 | } as any); | ||
| 565 | expect(result.kind).toBe("unchanged"); | ||
| 566 | }); | ||
| 567 | }); | ||
| 568 | }); | ||
packages/client-runtime/src/threadDetailReducer.ts created+523| ... | @@ -0,0 +1,523 @@ | ||
| 1 | import { pipe } from "effect/Function"; | ||
| 2 | import * as Arr from "effect/Array"; | ||
| 3 | import * as O from "effect/Order"; | ||
| 4 | import type { | ||
| 5 | MessageId, | ||
| 6 | OrchestrationCheckpointSummary, | ||
| 7 | OrchestrationEvent, | ||
| 8 | OrchestrationLatestTurn, | ||
| 9 | OrchestrationMessage, | ||
| 10 | OrchestrationThread, | ||
| 11 | OrchestrationThreadActivity, | ||
| 12 | TurnId, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | |||
| 15 | /** | ||
| 16 | * Retention limits for collections within a thread. | ||
| 17 | * These prevent unbounded growth of in-memory thread state. | ||
| 18 | */ | ||
| 19 | export interface ThreadDetailRetentionLimits { | ||
| 20 | readonly maxMessages: number; | ||
| 21 | readonly maxProposedPlans: number; | ||
| 22 | readonly maxCheckpoints: number; | ||
| 23 | readonly maxActivities: number; | ||
| 24 | } | ||
| 25 | |||
| 26 | export const DEFAULT_THREAD_DETAIL_LIMITS: ThreadDetailRetentionLimits = { | ||
| 27 | maxMessages: 512, | ||
| 28 | maxProposedPlans: 64, | ||
| 29 | maxCheckpoints: 256, | ||
| 30 | maxActivities: 128, | ||
| 31 | }; | ||
| 32 | |||
| 33 | export type ThreadDetailReducerResult = | ||
| 34 | | { readonly kind: "updated"; readonly thread: OrchestrationThread } | ||
| 35 | | { readonly kind: "deleted" } | ||
| 36 | | { readonly kind: "unchanged" }; | ||
| 37 | |||
| 38 | const proposedPlanOrder = O.combine<OrchestrationThread["proposedPlans"][number]>( | ||
| 39 | O.mapInput(O.String, (p) => p.createdAt), | ||
| 40 | O.mapInput(O.String, (p) => p.id), | ||
| 41 | ); | ||
| 42 | |||
| 43 | const checkpointOrder = O.mapInput( | ||
| 44 | O.Number, | ||
| 45 | (cp: OrchestrationThread["checkpoints"][number]) => | ||
| 46 | cp.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER, | ||
| 47 | ); | ||
| 48 | |||
| 49 | const activityOrder = O.combineAll<OrchestrationThreadActivity>([ | ||
| 50 | O.mapInput(O.Number, (a) => a.sequence ?? Number.MAX_SAFE_INTEGER), | ||
| 51 | O.mapInput(O.String, (a) => a.createdAt), | ||
| 52 | O.mapInput(O.String, (a) => a.id), | ||
| 53 | ]); | ||
| 54 | |||
| 55 | /** | ||
| 56 | * Apply a single orchestration event to an `OrchestrationThread`, returning | ||
| 57 | * the updated thread, a deletion signal, or an "unchanged" marker when the | ||
| 58 | * event doesn't affect this thread. | ||
| 59 | * | ||
| 60 | * This is a pure reducer operating on contract types. UI-specific mapping | ||
| 61 | * (e.g. resolving attachment preview URLs, normalising model slugs, adding | ||
| 62 | * scoped fields like `environmentId`) is the caller's responsibility. | ||
| 63 | */ | ||
| 64 | export function applyThreadDetailEvent( | ||
| 65 | thread: OrchestrationThread, | ||
| 66 | event: OrchestrationEvent, | ||
| 67 | limits: ThreadDetailRetentionLimits = DEFAULT_THREAD_DETAIL_LIMITS, | ||
| 68 | ): ThreadDetailReducerResult { | ||
| 69 | switch (event.type) { | ||
| 70 | // ── Project events (irrelevant to thread detail) ──────────────── | ||
| 71 | case "project.created": | ||
| 72 | case "project.meta-updated": | ||
| 73 | case "project.deleted": | ||
| 74 | return { kind: "unchanged" }; | ||
| 75 | |||
| 76 | // ── Thread lifecycle ──────────────────────────────────────────── | ||
| 77 | case "thread.created": | ||
| 78 | return { | ||
| 79 | kind: "updated", | ||
| 80 | thread: { | ||
| 81 | id: event.payload.threadId, | ||
| 82 | projectId: event.payload.projectId, | ||
| 83 | title: event.payload.title, | ||
| 84 | modelSelection: event.payload.modelSelection, | ||
| 85 | runtimeMode: event.payload.runtimeMode, | ||
| 86 | interactionMode: event.payload.interactionMode, | ||
| 87 | branch: event.payload.branch, | ||
| 88 | worktreePath: event.payload.worktreePath, | ||
| 89 | latestTurn: null, | ||
| 90 | createdAt: event.payload.createdAt, | ||
| 91 | updatedAt: event.payload.updatedAt, | ||
| 92 | archivedAt: null, | ||
| 93 | deletedAt: null, | ||
| 94 | messages: [], | ||
| 95 | proposedPlans: [], | ||
| 96 | activities: [], | ||
| 97 | checkpoints: [], | ||
| 98 | session: null, | ||
| 99 | }, | ||
| 100 | }; | ||
| 101 | |||
| 102 | case "thread.deleted": | ||
| 103 | return { kind: "deleted" }; | ||
| 104 | |||
| 105 | case "thread.archived": | ||
| 106 | return { | ||
| 107 | kind: "updated", | ||
| 108 | thread: { | ||
| 109 | ...thread, | ||
| 110 | archivedAt: event.payload.archivedAt, | ||
| 111 | updatedAt: event.payload.updatedAt, | ||
| 112 | }, | ||
| 113 | }; | ||
| 114 | |||
| 115 | case "thread.unarchived": | ||
| 116 | return { | ||
| 117 | kind: "updated", | ||
| 118 | thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, | ||
| 119 | }; | ||
| 120 | |||
| 121 | // ── Thread metadata ───────────────────────────────────────────── | ||
| 122 | case "thread.meta-updated": | ||
| 123 | return { | ||
| 124 | kind: "updated", | ||
| 125 | thread: { | ||
| 126 | ...thread, | ||
| 127 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), | ||
| 128 | ...(event.payload.modelSelection !== undefined | ||
| 129 | ? { modelSelection: event.payload.modelSelection } | ||
| 130 | : {}), | ||
| 131 | ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), | ||
| 132 | ...(event.payload.worktreePath !== undefined | ||
| 133 | ? { worktreePath: event.payload.worktreePath } | ||
| 134 | : {}), | ||
| 135 | updatedAt: event.payload.updatedAt, | ||
| 136 | }, | ||
| 137 | }; | ||
| 138 | |||
| 139 | case "thread.runtime-mode-set": | ||
| 140 | return { | ||
| 141 | kind: "updated", | ||
| 142 | thread: { | ||
| 143 | ...thread, | ||
| 144 | runtimeMode: event.payload.runtimeMode, | ||
| 145 | updatedAt: event.payload.updatedAt, | ||
| 146 | }, | ||
| 147 | }; | ||
| 148 | |||
| 149 | case "thread.interaction-mode-set": | ||
| 150 | return { | ||
| 151 | kind: "updated", | ||
| 152 | thread: { | ||
| 153 | ...thread, | ||
| 154 | interactionMode: event.payload.interactionMode, | ||
| 155 | updatedAt: event.payload.updatedAt, | ||
| 156 | }, | ||
| 157 | }; | ||
| 158 | |||
| 159 | // ── Turn lifecycle ────────────────────────────────────────────── | ||
| 160 | case "thread.turn-start-requested": | ||
| 161 | return { | ||
| 162 | kind: "updated", | ||
| 163 | thread: { | ||
| 164 | ...thread, | ||
| 165 | ...(event.payload.modelSelection !== undefined | ||
| 166 | ? { modelSelection: event.payload.modelSelection } | ||
| 167 | : {}), | ||
| 168 | runtimeMode: event.payload.runtimeMode, | ||
| 169 | interactionMode: event.payload.interactionMode, | ||
| 170 | updatedAt: event.occurredAt, | ||
| 171 | }, | ||
| 172 | }; | ||
| 173 | |||
| 174 | case "thread.turn-interrupt-requested": { | ||
| 175 | if (event.payload.turnId === undefined) { | ||
| 176 | return { kind: "unchanged" }; | ||
| 177 | } | ||
| 178 | const latestTurn = thread.latestTurn; | ||
| 179 | if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { | ||
| 180 | return { kind: "unchanged" }; | ||
| 181 | } | ||
| 182 | return { | ||
| 183 | kind: "updated", | ||
| 184 | thread: { | ||
| 185 | ...thread, | ||
| 186 | latestTurn: { | ||
| 187 | ...latestTurn, | ||
| 188 | state: "interrupted", | ||
| 189 | startedAt: latestTurn.startedAt ?? event.payload.createdAt, | ||
| 190 | completedAt: latestTurn.completedAt ?? event.payload.createdAt, | ||
| 191 | }, | ||
| 192 | updatedAt: event.occurredAt, | ||
| 193 | }, | ||
| 194 | }; | ||
| 195 | } | ||
| 196 | |||
| 197 | // ── Messages ──────────────────────────────────────────────────── | ||
| 198 | case "thread.message-sent": { | ||
| 199 | const message: OrchestrationMessage = { | ||
| 200 | id: event.payload.messageId, | ||
| 201 | role: event.payload.role, | ||
| 202 | text: event.payload.text, | ||
| 203 | ...(event.payload.attachments !== undefined | ||
| 204 | ? { attachments: event.payload.attachments } | ||
| 205 | : {}), | ||
| 206 | turnId: event.payload.turnId, | ||
| 207 | streaming: event.payload.streaming, | ||
| 208 | createdAt: event.payload.createdAt, | ||
| 209 | updatedAt: event.payload.updatedAt, | ||
| 210 | }; | ||
| 211 | |||
| 212 | const existingMessage = thread.messages.find((entry) => entry.id === message.id); | ||
| 213 | const messages = existingMessage | ||
| 214 | ? Arr.map(thread.messages, (entry) => | ||
| 215 | entry.id !== message.id | ||
| 216 | ? entry | ||
| 217 | : { | ||
| 218 | ...entry, | ||
| 219 | text: message.streaming | ||
| 220 | ? `${entry.text}${message.text}` | ||
| 221 | : message.text.length > 0 | ||
| 222 | ? message.text | ||
| 223 | : entry.text, | ||
| 224 | streaming: message.streaming, | ||
| 225 | ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), | ||
| 226 | ...(message.streaming ? {} : { updatedAt: message.updatedAt }), | ||
| 227 | ...(message.attachments !== undefined | ||
| 228 | ? { attachments: message.attachments } | ||
| 229 | : {}), | ||
| 230 | }, | ||
| 231 | ) | ||
| 232 | : Arr.append(thread.messages, message); | ||
| 233 | const cappedMessages = Arr.takeRight(messages, limits.maxMessages); | ||
| 234 | |||
| 235 | // Update latestTurn for assistant messages bound to a turn. | ||
| 236 | const latestTurn: OrchestrationThread["latestTurn"] = | ||
| 237 | event.payload.role === "assistant" && | ||
| 238 | event.payload.turnId !== null && | ||
| 239 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | ||
| 240 | ? { | ||
| 241 | turnId: event.payload.turnId, | ||
| 242 | state: event.payload.streaming | ||
| 243 | ? "running" | ||
| 244 | : thread.latestTurn?.state === "interrupted" | ||
| 245 | ? "interrupted" | ||
| 246 | : thread.latestTurn?.state === "error" | ||
| 247 | ? "error" | ||
| 248 | : "completed", | ||
| 249 | requestedAt: | ||
| 250 | thread.latestTurn?.turnId === event.payload.turnId | ||
| 251 | ? thread.latestTurn.requestedAt | ||
| 252 | : event.payload.createdAt, | ||
| 253 | startedAt: | ||
| 254 | thread.latestTurn?.turnId === event.payload.turnId | ||
| 255 | ? (thread.latestTurn.startedAt ?? event.payload.createdAt) | ||
| 256 | : event.payload.createdAt, | ||
| 257 | completedAt: event.payload.streaming | ||
| 258 | ? thread.latestTurn?.turnId === event.payload.turnId | ||
| 259 | ? (thread.latestTurn.completedAt ?? null) | ||
| 260 | : null | ||
| 261 | : event.payload.updatedAt, | ||
| 262 | assistantMessageId: event.payload.messageId, | ||
| 263 | } | ||
| 264 | : thread.latestTurn; | ||
| 265 | |||
| 266 | // Rebind checkpoint assistant message IDs for assistant messages. | ||
| 267 | const checkpoints = | ||
| 268 | event.payload.role === "assistant" && event.payload.turnId !== null | ||
| 269 | ? rebindCheckpointAssistantMessage( | ||
| 270 | thread.checkpoints, | ||
| 271 | event.payload.turnId, | ||
| 272 | event.payload.messageId, | ||
| 273 | ) | ||
| 274 | : thread.checkpoints; | ||
| 275 | |||
| 276 | return { | ||
| 277 | kind: "updated", | ||
| 278 | thread: { | ||
| 279 | ...thread, | ||
| 280 | messages: cappedMessages, | ||
| 281 | checkpoints, | ||
| 282 | latestTurn, | ||
| 283 | updatedAt: event.occurredAt, | ||
| 284 | }, | ||
| 285 | }; | ||
| 286 | } | ||
| 287 | |||
| 288 | // ── Session ───────────────────────────────────────────────────── | ||
| 289 | case "thread.session-set": { | ||
| 290 | const latestTurn: OrchestrationLatestTurn | null = | ||
| 291 | event.payload.session.status === "running" && event.payload.session.activeTurnId !== null | ||
| 292 | ? { | ||
| 293 | turnId: event.payload.session.activeTurnId, | ||
| 294 | state: "running", | ||
| 295 | requestedAt: | ||
| 296 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 297 | ? thread.latestTurn.requestedAt | ||
| 298 | : event.payload.session.updatedAt, | ||
| 299 | startedAt: | ||
| 300 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 301 | ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) | ||
| 302 | : event.payload.session.updatedAt, | ||
| 303 | completedAt: null, | ||
| 304 | assistantMessageId: | ||
| 305 | thread.latestTurn?.turnId === event.payload.session.activeTurnId | ||
| 306 | ? thread.latestTurn.assistantMessageId | ||
| 307 | : null, | ||
| 308 | } | ||
| 309 | : thread.latestTurn; | ||
| 310 | |||
| 311 | return { | ||
| 312 | kind: "updated", | ||
| 313 | thread: { | ||
| 314 | ...thread, | ||
| 315 | session: event.payload.session, | ||
| 316 | latestTurn, | ||
| 317 | updatedAt: event.occurredAt, | ||
| 318 | }, | ||
| 319 | }; | ||
| 320 | } | ||
| 321 | |||
| 322 | case "thread.session-stop-requested": | ||
| 323 | return thread.session === null | ||
| 324 | ? { kind: "unchanged" } | ||
| 325 | : { | ||
| 326 | kind: "updated", | ||
| 327 | thread: { | ||
| 328 | ...thread, | ||
| 329 | session: { | ||
| 330 | ...thread.session, | ||
| 331 | status: "stopped", | ||
| 332 | activeTurnId: null, | ||
| 333 | updatedAt: event.payload.createdAt, | ||
| 334 | }, | ||
| 335 | updatedAt: event.occurredAt, | ||
| 336 | }, | ||
| 337 | }; | ||
| 338 | |||
| 339 | // ── Proposed plans ────────────────────────────────────────────── | ||
| 340 | case "thread.proposed-plan-upserted": { | ||
| 341 | const proposedPlan = event.payload.proposedPlan; | ||
| 342 | |||
| 343 | const proposedPlans = pipe( | ||
| 344 | thread.proposedPlans, | ||
| 345 | Arr.filter((entry) => entry.id !== proposedPlan.id), | ||
| 346 | Arr.append(proposedPlan), | ||
| 347 | Arr.sort(proposedPlanOrder), | ||
| 348 | Arr.takeRight(limits.maxProposedPlans), | ||
| 349 | ); | ||
| 350 | |||
| 351 | return { | ||
| 352 | kind: "updated", | ||
| 353 | thread: { ...thread, proposedPlans, updatedAt: event.occurredAt }, | ||
| 354 | }; | ||
| 355 | } | ||
| 356 | |||
| 357 | // ── Checkpoints / turn diffs ──────────────────────────────────── | ||
| 358 | case "thread.turn-diff-completed": { | ||
| 359 | const checkpoint: OrchestrationCheckpointSummary = { | ||
| 360 | turnId: event.payload.turnId, | ||
| 361 | checkpointTurnCount: event.payload.checkpointTurnCount, | ||
| 362 | checkpointRef: event.payload.checkpointRef, | ||
| 363 | status: event.payload.status, | ||
| 364 | files: event.payload.files, | ||
| 365 | assistantMessageId: event.payload.assistantMessageId, | ||
| 366 | completedAt: event.payload.completedAt, | ||
| 367 | }; | ||
| 368 | |||
| 369 | const existing = thread.checkpoints.find((entry) => entry.turnId === checkpoint.turnId); | ||
| 370 | // Don't overwrite a non-missing checkpoint with a missing one. | ||
| 371 | if (existing && existing.status !== "missing" && checkpoint.status === "missing") { | ||
| 372 | return { kind: "unchanged" }; | ||
| 373 | } | ||
| 374 | |||
| 375 | const checkpoints = pipe( | ||
| 376 | thread.checkpoints, | ||
| 377 | Arr.filter((entry) => entry.turnId !== checkpoint.turnId), | ||
| 378 | Arr.append(checkpoint), | ||
| 379 | Arr.sort(checkpointOrder), | ||
| 380 | Arr.takeRight(limits.maxCheckpoints), | ||
| 381 | ); | ||
| 382 | |||
| 383 | const latestTurn = | ||
| 384 | thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId | ||
| 385 | ? { | ||
| 386 | turnId: event.payload.turnId, | ||
| 387 | state: checkpointStatusToTurnState(event.payload.status), | ||
| 388 | requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, | ||
| 389 | startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, | ||
| 390 | completedAt: event.payload.completedAt, | ||
| 391 | assistantMessageId: event.payload.assistantMessageId, | ||
| 392 | } | ||
| 393 | : thread.latestTurn; | ||
| 394 | |||
| 395 | return { | ||
| 396 | kind: "updated", | ||
| 397 | thread: { ...thread, checkpoints, latestTurn, updatedAt: event.occurredAt }, | ||
| 398 | }; | ||
| 399 | } | ||
| 400 | |||
| 401 | // ── Revert ────────────────────────────────────────────────────── | ||
| 402 | case "thread.reverted": { | ||
| 403 | const checkpoints = pipe( | ||
| 404 | thread.checkpoints, | ||
| 405 | Arr.filter( | ||
| 406 | (entry) => | ||
| 407 | entry.checkpointTurnCount !== undefined && | ||
| 408 | entry.checkpointTurnCount <= event.payload.turnCount, | ||
| 409 | ), | ||
| 410 | Arr.sort(checkpointOrder), | ||
| 411 | Arr.takeRight(limits.maxCheckpoints), | ||
| 412 | ); | ||
| 413 | |||
| 414 | const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); | ||
| 415 | const messages = pipe( | ||
| 416 | retainMessagesAfterRevert(thread.messages, retainedTurnIds), | ||
| 417 | Arr.takeRight(limits.maxMessages), | ||
| 418 | ); | ||
| 419 | const proposedPlans = pipe( | ||
| 420 | thread.proposedPlans, | ||
| 421 | Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), | ||
| 422 | Arr.takeRight(limits.maxProposedPlans), | ||
| 423 | ); | ||
| 424 | const activities = pipe( | ||
| 425 | thread.activities, | ||
| 426 | Arr.filter((activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId)), | ||
| 427 | ); | ||
| 428 | const latestCheckpoint = checkpoints.at(-1) ?? null; | ||
| 429 | |||
| 430 | return { | ||
| 431 | kind: "updated", | ||
| 432 | thread: { | ||
| 433 | ...thread, | ||
| 434 | checkpoints, | ||
| 435 | messages, | ||
| 436 | proposedPlans, | ||
| 437 | activities, | ||
| 438 | latestTurn: | ||
| 439 | latestCheckpoint === null | ||
| 440 | ? null | ||
| 441 | : { | ||
| 442 | turnId: latestCheckpoint.turnId, | ||
| 443 | state: checkpointStatusToTurnState( | ||
| 444 | latestCheckpoint.status as "ready" | "missing" | "error", | ||
| 445 | ), | ||
| 446 | requestedAt: latestCheckpoint.completedAt, | ||
| 447 | startedAt: latestCheckpoint.completedAt, | ||
| 448 | completedAt: latestCheckpoint.completedAt, | ||
| 449 | assistantMessageId: latestCheckpoint.assistantMessageId ?? null, | ||
| 450 | }, | ||
| 451 | updatedAt: event.occurredAt, | ||
| 452 | }, | ||
| 453 | }; | ||
| 454 | } | ||
| 455 | |||
| 456 | // ── Activities ────────────────────────────────────────────────── | ||
| 457 | case "thread.activity-appended": { | ||
| 458 | const activities = pipe( | ||
| 459 | thread.activities, | ||
| 460 | Arr.filter((activity) => activity.id !== event.payload.activity.id), | ||
| 461 | Arr.append(event.payload.activity), | ||
| 462 | Arr.sort(activityOrder), | ||
| 463 | Arr.takeRight(limits.maxActivities), | ||
| 464 | ); | ||
| 465 | |||
| 466 | return { | ||
| 467 | kind: "updated", | ||
| 468 | thread: { ...thread, activities, updatedAt: event.occurredAt }, | ||
| 469 | }; | ||
| 470 | } | ||
| 471 | |||
| 472 | // ── Events that don't mutate thread state directly ────────────── | ||
| 473 | case "thread.approval-response-requested": | ||
| 474 | case "thread.user-input-response-requested": | ||
| 475 | case "thread.checkpoint-revert-requested": | ||
| 476 | return { kind: "unchanged" }; | ||
| 477 | } | ||
| 478 | |||
| 479 | // Forward-compatible: ignore unrecognized event types. | ||
| 480 | return { kind: "unchanged" }; | ||
| 481 | } | ||
| 482 | |||
| 483 | // ── Helpers ────────────────────────────────────────────────────────── | ||
| 484 | |||
| 485 | function checkpointStatusToTurnState( | ||
| 486 | status: "ready" | "missing" | "error", | ||
| 487 | ): OrchestrationLatestTurn["state"] { | ||
| 488 | switch (status) { | ||
| 489 | case "ready": | ||
| 490 | return "completed"; | ||
| 491 | case "error": | ||
| 492 | return "error"; | ||
| 493 | case "missing": | ||
| 494 | return "completed"; | ||
| 495 | } | ||
| 496 | } | ||
| 497 | |||
| 498 | function rebindCheckpointAssistantMessage( | ||
| 499 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, | ||
| 500 | turnId: TurnId, | ||
| 501 | messageId: MessageId, | ||
| 502 | ): OrchestrationCheckpointSummary[] { | ||
| 503 | return Arr.map(checkpoints, (entry) => | ||
| 504 | entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, | ||
| 505 | ); | ||
| 506 | } | ||
| 507 | |||
| 508 | function retainMessagesAfterRevert( | ||
| 509 | messages: ReadonlyArray<OrchestrationMessage>, | ||
| 510 | retainedTurnIds: ReadonlySet<string>, | ||
| 511 | ): OrchestrationMessage[] { | ||
| 512 | // Keep messages that belong to a retained turn, plus system messages and | ||
| 513 | // messages without a turn binding (pre-turn-0 user messages). | ||
| 514 | return Arr.filter(messages, (message) => { | ||
| 515 | if (message.role === "system") { | ||
| 516 | return true; | ||
| 517 | } | ||
| 518 | if (message.turnId === null) { | ||
| 519 | return true; | ||
| 520 | } | ||
| 521 | return retainedTurnIds.has(message.turnId); | ||
| 522 | }); | ||
| 523 | } | ||
packages/client-runtime/src/threadDetailState.test.ts created+322| ... | @@ -0,0 +1,322 @@ | ||
| 1 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | EventId, | ||
| 6 | EnvironmentId, | ||
| 7 | MessageId, | ||
| 8 | ProjectId, | ||
| 9 | ProviderInstanceId, | ||
| 10 | ThreadId, | ||
| 11 | TurnId, | ||
| 12 | type OrchestrationThread, | ||
| 13 | type OrchestrationThreadStreamItem, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | import { createThreadDetailManager, type ThreadDetailClient } from "./threadDetailState.ts"; | ||
| 17 | |||
| 18 | let atomRegistry = AtomRegistry.make(); | ||
| 19 | |||
| 20 | function resetAtomRegistry() { | ||
| 21 | atomRegistry.dispose(); | ||
| 22 | atomRegistry = AtomRegistry.make(); | ||
| 23 | } | ||
| 24 | |||
| 25 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | ||
| 26 | listeners.add(listener); | ||
| 27 | return () => { | ||
| 28 | listeners.delete(listener); | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | |||
| 32 | const baseEventFields = { | ||
| 33 | eventId: EventId.make("event-1"), | ||
| 34 | commandId: null, | ||
| 35 | causationEventId: null, | ||
| 36 | correlationId: null, | ||
| 37 | metadata: {}, | ||
| 38 | } as const; | ||
| 39 | |||
| 40 | const BASE_THREAD: OrchestrationThread = { | ||
| 41 | id: ThreadId.make("thread-1"), | ||
| 42 | projectId: ProjectId.make("project-1"), | ||
| 43 | title: "Test Thread", | ||
| 44 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 45 | runtimeMode: "full-access", | ||
| 46 | interactionMode: "default", | ||
| 47 | branch: null, | ||
| 48 | worktreePath: null, | ||
| 49 | latestTurn: null, | ||
| 50 | createdAt: "2026-04-01T00:00:00.000Z", | ||
| 51 | updatedAt: "2026-04-01T00:00:00.000Z", | ||
| 52 | archivedAt: null, | ||
| 53 | deletedAt: null, | ||
| 54 | messages: [], | ||
| 55 | proposedPlans: [], | ||
| 56 | activities: [], | ||
| 57 | checkpoints: [], | ||
| 58 | session: null, | ||
| 59 | }; | ||
| 60 | |||
| 61 | const TARGET = { | ||
| 62 | environmentId: EnvironmentId.make("env-local"), | ||
| 63 | threadId: ThreadId.make("thread-1"), | ||
| 64 | } as const; | ||
| 65 | |||
| 66 | function createMockClient(): { | ||
| 67 | client: ThreadDetailClient; | ||
| 68 | listeners: Set<(event: OrchestrationThreadStreamItem) => void>; | ||
| 69 | emit: (event: OrchestrationThreadStreamItem) => void; | ||
| 70 | } { | ||
| 71 | const listeners = new Set<(event: OrchestrationThreadStreamItem) => void>(); | ||
| 72 | const client: ThreadDetailClient = { | ||
| 73 | subscribeThread: vi.fn((_input, listener: (event: OrchestrationThreadStreamItem) => void) => | ||
| 74 | registerListener(listeners, listener), | ||
| 75 | ), | ||
| 76 | }; | ||
| 77 | |||
| 78 | return { | ||
| 79 | client, | ||
| 80 | listeners, | ||
| 81 | emit: (event) => { | ||
| 82 | for (const listener of listeners) { | ||
| 83 | listener(event); | ||
| 84 | } | ||
| 85 | }, | ||
| 86 | }; | ||
| 87 | } | ||
| 88 | |||
| 89 | describe("createThreadDetailManager", () => { | ||
| 90 | afterEach(() => { | ||
| 91 | vi.useRealTimers(); | ||
| 92 | resetAtomRegistry(); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it("starts in a pending state when watching", () => { | ||
| 96 | const { client } = createMockClient(); | ||
| 97 | const manager = createThreadDetailManager({ | ||
| 98 | getRegistry: () => atomRegistry, | ||
| 99 | getClient: () => null, | ||
| 100 | }); | ||
| 101 | |||
| 102 | manager.watch(TARGET, client); | ||
| 103 | |||
| 104 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 105 | data: null, | ||
| 106 | error: null, | ||
| 107 | isPending: true, | ||
| 108 | isDeleted: false, | ||
| 109 | }); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it("applies snapshots and incremental events", () => { | ||
| 113 | const { client, emit } = createMockClient(); | ||
| 114 | const manager = createThreadDetailManager({ | ||
| 115 | getRegistry: () => atomRegistry, | ||
| 116 | getClient: () => null, | ||
| 117 | }); | ||
| 118 | |||
| 119 | const release = manager.watch(TARGET, client); | ||
| 120 | |||
| 121 | emit({ | ||
| 122 | kind: "snapshot", | ||
| 123 | snapshot: { | ||
| 124 | snapshotSequence: 1, | ||
| 125 | thread: BASE_THREAD, | ||
| 126 | }, | ||
| 127 | }); | ||
| 128 | |||
| 129 | emit({ | ||
| 130 | kind: "event", | ||
| 131 | event: { | ||
| 132 | ...baseEventFields, | ||
| 133 | sequence: 2, | ||
| 134 | occurredAt: "2026-04-01T01:00:00.000Z", | ||
| 135 | aggregateKind: "thread", | ||
| 136 | aggregateId: ThreadId.make("thread-1"), | ||
| 137 | type: "thread.message-sent", | ||
| 138 | payload: { | ||
| 139 | threadId: ThreadId.make("thread-1"), | ||
| 140 | turnId: TurnId.make("turn-1"), | ||
| 141 | messageId: MessageId.make("message-1"), | ||
| 142 | role: "assistant", | ||
| 143 | text: "hello", | ||
| 144 | streaming: false, | ||
| 145 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 146 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 147 | }, | ||
| 148 | } as any, | ||
| 149 | }); | ||
| 150 | |||
| 151 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 152 | data: { | ||
| 153 | ...BASE_THREAD, | ||
| 154 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 155 | latestTurn: { | ||
| 156 | turnId: TurnId.make("turn-1"), | ||
| 157 | state: "completed", | ||
| 158 | requestedAt: "2026-04-01T01:00:00.000Z", | ||
| 159 | startedAt: "2026-04-01T01:00:00.000Z", | ||
| 160 | completedAt: "2026-04-01T01:00:00.000Z", | ||
| 161 | assistantMessageId: MessageId.make("message-1"), | ||
| 162 | }, | ||
| 163 | messages: [ | ||
| 164 | { | ||
| 165 | id: MessageId.make("message-1"), | ||
| 166 | role: "assistant", | ||
| 167 | text: "hello", | ||
| 168 | turnId: TurnId.make("turn-1"), | ||
| 169 | streaming: false, | ||
| 170 | createdAt: "2026-04-01T01:00:00.000Z", | ||
| 171 | updatedAt: "2026-04-01T01:00:00.000Z", | ||
| 172 | }, | ||
| 173 | ], | ||
| 174 | }, | ||
| 175 | error: null, | ||
| 176 | isPending: false, | ||
| 177 | isDeleted: false, | ||
| 178 | }); | ||
| 179 | |||
| 180 | release(); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("marks threads as deleted when the stream deletes them", () => { | ||
| 184 | const { client, emit } = createMockClient(); | ||
| 185 | const manager = createThreadDetailManager({ | ||
| 186 | getRegistry: () => atomRegistry, | ||
| 187 | getClient: () => null, | ||
| 188 | }); | ||
| 189 | |||
| 190 | const release = manager.watch(TARGET, client); | ||
| 191 | |||
| 192 | emit({ | ||
| 193 | kind: "snapshot", | ||
| 194 | snapshot: { | ||
| 195 | snapshotSequence: 1, | ||
| 196 | thread: BASE_THREAD, | ||
| 197 | }, | ||
| 198 | }); | ||
| 199 | emit({ | ||
| 200 | kind: "event", | ||
| 201 | event: { | ||
| 202 | ...baseEventFields, | ||
| 203 | sequence: 3, | ||
| 204 | occurredAt: "2026-04-01T01:10:00.000Z", | ||
| 205 | aggregateKind: "thread", | ||
| 206 | aggregateId: ThreadId.make("thread-1"), | ||
| 207 | type: "thread.deleted", | ||
| 208 | payload: { | ||
| 209 | threadId: ThreadId.make("thread-1"), | ||
| 210 | deletedAt: "2026-04-01T01:10:00.000Z", | ||
| 211 | }, | ||
| 212 | } as any, | ||
| 213 | }); | ||
| 214 | |||
| 215 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 216 | data: null, | ||
| 217 | error: null, | ||
| 218 | isPending: false, | ||
| 219 | isDeleted: true, | ||
| 220 | }); | ||
| 221 | |||
| 222 | release(); | ||
| 223 | }); | ||
| 224 | |||
| 225 | it("waits for delayed client registration when subscribeClientChanges is configured", () => { | ||
| 226 | const connectionListeners = new Set<() => void>(); | ||
| 227 | const clients = new Map<string, ReturnType<typeof createMockClient>>(); | ||
| 228 | const manager = createThreadDetailManager({ | ||
| 229 | getRegistry: () => atomRegistry, | ||
| 230 | getClient: (environmentId) => clients.get(environmentId)?.client ?? null, | ||
| 231 | getClientIdentity: (environmentId) => (clients.has(environmentId) ? environmentId : null), | ||
| 232 | subscribeClientChanges: (listener) => { | ||
| 233 | connectionListeners.add(listener); | ||
| 234 | return () => connectionListeners.delete(listener); | ||
| 235 | }, | ||
| 236 | }); | ||
| 237 | |||
| 238 | const release = manager.watch(TARGET); | ||
| 239 | expect(manager.getSnapshot(TARGET).isPending).toBe(true); | ||
| 240 | |||
| 241 | const mock = createMockClient(); | ||
| 242 | clients.set("env-local", mock); | ||
| 243 | for (const listener of connectionListeners) { | ||
| 244 | listener(); | ||
| 245 | } | ||
| 246 | |||
| 247 | mock.emit({ | ||
| 248 | kind: "snapshot", | ||
| 249 | snapshot: { | ||
| 250 | snapshotSequence: 1, | ||
| 251 | thread: BASE_THREAD, | ||
| 252 | }, | ||
| 253 | }); | ||
| 254 | |||
| 255 | expect(manager.getSnapshot(TARGET).data?.id).toBe(ThreadId.make("thread-1")); | ||
| 256 | |||
| 257 | release(); | ||
| 258 | }); | ||
| 259 | |||
| 260 | it("evicts idle subscriptions after the configured ttl", () => { | ||
| 261 | vi.useFakeTimers(); | ||
| 262 | const mock = createMockClient(); | ||
| 263 | const manager = createThreadDetailManager({ | ||
| 264 | getRegistry: () => atomRegistry, | ||
| 265 | getClient: () => mock.client, | ||
| 266 | retention: { | ||
| 267 | idleTtlMs: 60_000, | ||
| 268 | maxRetainedEntries: 10, | ||
| 269 | }, | ||
| 270 | }); | ||
| 271 | |||
| 272 | const release = manager.watch(TARGET); | ||
| 273 | expect(mock.listeners.size).toBe(1); | ||
| 274 | |||
| 275 | release(); | ||
| 276 | expect(mock.listeners.size).toBe(1); | ||
| 277 | |||
| 278 | vi.advanceTimersByTime(60_000); | ||
| 279 | expect(mock.listeners.size).toBe(0); | ||
| 280 | }); | ||
| 281 | |||
| 282 | it("keeps non-idle threads warm when the retention policy says to", () => { | ||
| 283 | vi.useFakeTimers(); | ||
| 284 | const mock = createMockClient(); | ||
| 285 | const manager = createThreadDetailManager({ | ||
| 286 | getRegistry: () => atomRegistry, | ||
| 287 | getClient: () => mock.client, | ||
| 288 | retention: { | ||
| 289 | idleTtlMs: 60_000, | ||
| 290 | maxRetainedEntries: 10, | ||
| 291 | shouldKeepWarm: (_target, state) => state.data?.session?.status === "running", | ||
| 292 | }, | ||
| 293 | }); | ||
| 294 | |||
| 295 | const release = manager.watch(TARGET); | ||
| 296 | mock.emit({ | ||
| 297 | kind: "snapshot", | ||
| 298 | snapshot: { | ||
| 299 | snapshotSequence: 1, | ||
| 300 | thread: { | ||
| 301 | ...BASE_THREAD, | ||
| 302 | session: { | ||
| 303 | threadId: ThreadId.make("thread-1"), | ||
| 304 | status: "running", | ||
| 305 | providerName: "codex", | ||
| 306 | runtimeMode: "full-access", | ||
| 307 | activeTurnId: TurnId.make("turn-1"), | ||
| 308 | lastError: null, | ||
| 309 | updatedAt: "2026-04-01T00:10:00.000Z", | ||
| 310 | }, | ||
| 311 | }, | ||
| 312 | }, | ||
| 313 | }); | ||
| 314 | |||
| 315 | release(); | ||
| 316 | vi.advanceTimersByTime(60_000); | ||
| 317 | |||
| 318 | expect(mock.listeners.size).toBe(1); | ||
| 319 | manager.reset(); | ||
| 320 | expect(mock.listeners.size).toBe(0); | ||
| 321 | }); | ||
| 322 | }); | ||
packages/client-runtime/src/threadDetailState.ts created+444| ... | @@ -0,0 +1,444 @@ | ||
| 1 | import { pipe } from "effect/Function"; | ||
| 2 | import * as Order from "effect/Order"; | ||
| 3 | import * as Arr from "effect/Array"; | ||
| 4 | import * as DateTime from "effect/DateTime"; | ||
| 5 | import * as Duration from "effect/Duration"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Fiber from "effect/Fiber"; | ||
| 8 | import type { | ||
| 9 | OrchestrationThread, | ||
| 10 | OrchestrationThreadStreamItem, | ||
| 11 | EnvironmentId, | ||
| 12 | ThreadId as ThreadIdType, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | DEFAULT_THREAD_DETAIL_LIMITS, | ||
| 18 | applyThreadDetailEvent, | ||
| 19 | type ThreadDetailRetentionLimits, | ||
| 20 | } from "./threadDetailReducer.ts"; | ||
| 21 | import type { WsRpcClient } from "./wsRpcClient.ts"; | ||
| 22 | |||
| 23 | export interface ThreadDetailState { | ||
| 24 | readonly data: OrchestrationThread | null; | ||
| 25 | readonly error: string | null; | ||
| 26 | readonly isPending: boolean; | ||
| 27 | readonly isDeleted: boolean; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface ThreadDetailTarget { | ||
| 31 | readonly environmentId: EnvironmentId | null; | ||
| 32 | readonly threadId: ThreadIdType | null; | ||
| 33 | } | ||
| 34 | |||
| 35 | export type ThreadDetailClient = Pick<WsRpcClient["orchestration"], "subscribeThread">; | ||
| 36 | |||
| 37 | export interface ThreadDetailRetentionPolicy { | ||
| 38 | readonly idleTtlMs: number; | ||
| 39 | readonly maxRetainedEntries: number; | ||
| 40 | readonly shouldKeepWarm?: ( | ||
| 41 | target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, | ||
| 42 | state: ThreadDetailState, | ||
| 43 | ) => boolean; | ||
| 44 | } | ||
| 45 | |||
| 46 | interface ThreadDetailEntry { | ||
| 47 | readonly target: { | ||
| 48 | readonly environmentId: EnvironmentId; | ||
| 49 | readonly threadId: ThreadIdType; | ||
| 50 | }; | ||
| 51 | watcherCount: number; | ||
| 52 | retainCount: number; | ||
| 53 | teardown: () => void; | ||
| 54 | lastAccessedAt: number; | ||
| 55 | evictionFiber: Fiber.Fiber<void, never> | null; | ||
| 56 | } | ||
| 57 | |||
| 58 | const NOOP: () => void = () => undefined; | ||
| 59 | const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); | ||
| 60 | |||
| 61 | function clearEntryEviction(entry: ThreadDetailEntry): void { | ||
| 62 | if (entry.evictionFiber !== null) { | ||
| 63 | Effect.runFork(Fiber.interrupt(entry.evictionFiber)); | ||
| 64 | entry.evictionFiber = null; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | export const EMPTY_THREAD_DETAIL_STATE = Object.freeze<ThreadDetailState>({ | ||
| 69 | data: null, | ||
| 70 | error: null, | ||
| 71 | isPending: false, | ||
| 72 | isDeleted: false, | ||
| 73 | }); | ||
| 74 | |||
| 75 | const INITIAL_THREAD_DETAIL_STATE = Object.freeze<ThreadDetailState>({ | ||
| 76 | data: null, | ||
| 77 | error: null, | ||
| 78 | isPending: true, | ||
| 79 | isDeleted: false, | ||
| 80 | }); | ||
| 81 | |||
| 82 | const knownThreadDetailKeys = new Set<string>(); | ||
| 83 | |||
| 84 | export const threadDetailStateAtom = Atom.family((key: string) => { | ||
| 85 | knownThreadDetailKeys.add(key); | ||
| 86 | return Atom.make(INITIAL_THREAD_DETAIL_STATE).pipe( | ||
| 87 | Atom.keepAlive, | ||
| 88 | Atom.withLabel(`thread-detail:${key}`), | ||
| 89 | ); | ||
| 90 | }); | ||
| 91 | |||
| 92 | export const EMPTY_THREAD_DETAIL_ATOM = Atom.make(EMPTY_THREAD_DETAIL_STATE).pipe( | ||
| 93 | Atom.keepAlive, | ||
| 94 | Atom.withLabel("thread-detail:null"), | ||
| 95 | ); | ||
| 96 | |||
| 97 | export function getThreadDetailTargetKey(target: ThreadDetailTarget): string | null { | ||
| 98 | if (target.environmentId === null || target.threadId === null) { | ||
| 99 | return null; | ||
| 100 | } | ||
| 101 | |||
| 102 | return `${target.environmentId}:${target.threadId}`; | ||
| 103 | } | ||
| 104 | |||
| 105 | export interface ThreadDetailManagerConfig { | ||
| 106 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 107 | readonly getClient: (environmentId: EnvironmentId) => ThreadDetailClient | null; | ||
| 108 | readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null; | ||
| 109 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 110 | readonly limits?: ThreadDetailRetentionLimits; | ||
| 111 | readonly retention?: ThreadDetailRetentionPolicy; | ||
| 112 | } | ||
| 113 | |||
| 114 | export function createThreadDetailManager(config: ThreadDetailManagerConfig) { | ||
| 115 | const entries = new Map<string, ThreadDetailEntry>(); | ||
| 116 | |||
| 117 | function getSnapshot(target: ThreadDetailTarget): ThreadDetailState { | ||
| 118 | const targetKey = getThreadDetailTargetKey(target); | ||
| 119 | if (targetKey === null) { | ||
| 120 | return EMPTY_THREAD_DETAIL_STATE; | ||
| 121 | } | ||
| 122 | |||
| 123 | return config.getRegistry().get(threadDetailStateAtom(targetKey)); | ||
| 124 | } | ||
| 125 | |||
| 126 | function setState(targetKey: string, nextState: ThreadDetailState): void { | ||
| 127 | config.getRegistry().set(threadDetailStateAtom(targetKey), nextState); | ||
| 128 | reconcileRetention(targetKey); | ||
| 129 | } | ||
| 130 | |||
| 131 | function markPending(targetKey: string): void { | ||
| 132 | const current = config.getRegistry().get(threadDetailStateAtom(targetKey)); | ||
| 133 | setState(targetKey, { | ||
| 134 | ...current, | ||
| 135 | error: null, | ||
| 136 | isPending: true, | ||
| 137 | }); | ||
| 138 | } | ||
| 139 | |||
| 140 | function setData(targetKey: string, thread: OrchestrationThread): void { | ||
| 141 | setState(targetKey, { | ||
| 142 | data: thread, | ||
| 143 | error: null, | ||
| 144 | isPending: false, | ||
| 145 | isDeleted: false, | ||
| 146 | }); | ||
| 147 | } | ||
| 148 | |||
| 149 | function setDeleted(targetKey: string): void { | ||
| 150 | setState(targetKey, { | ||
| 151 | data: null, | ||
| 152 | error: null, | ||
| 153 | isPending: false, | ||
| 154 | isDeleted: true, | ||
| 155 | }); | ||
| 156 | } | ||
| 157 | |||
| 158 | function shouldKeepWarm(entry: ThreadDetailEntry): boolean { | ||
| 159 | return config.retention?.shouldKeepWarm?.(entry.target, getSnapshot(entry.target)) ?? false; | ||
| 160 | } | ||
| 161 | |||
| 162 | function disposeEntry(targetKey: string): void { | ||
| 163 | const entry = entries.get(targetKey); | ||
| 164 | if (!entry) { | ||
| 165 | return; | ||
| 166 | } | ||
| 167 | |||
| 168 | clearEntryEviction(entry); | ||
| 169 | entry.teardown(); | ||
| 170 | entries.delete(targetKey); | ||
| 171 | } | ||
| 172 | |||
| 173 | function evictIdleEntriesToCapacity(): void { | ||
| 174 | const retention = config.retention; | ||
| 175 | if (!retention || entries.size <= retention.maxRetainedEntries) { | ||
| 176 | return; | ||
| 177 | } | ||
| 178 | |||
| 179 | const idleEntries = pipe( | ||
| 180 | Arr.fromIterable(entries), | ||
| 181 | Arr.filter( | ||
| 182 | ([, entry]) => | ||
| 183 | entry.watcherCount === 0 && entry.retainCount === 0 && !shouldKeepWarm(entry), | ||
| 184 | ), | ||
| 185 | Arr.sortWith(([, e]) => e.lastAccessedAt, Order.Number), | ||
| 186 | ); | ||
| 187 | |||
| 188 | for (const [targetKey] of idleEntries) { | ||
| 189 | if (entries.size <= retention.maxRetainedEntries) { | ||
| 190 | return; | ||
| 191 | } | ||
| 192 | disposeEntry(targetKey); | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | function scheduleEviction(targetKey: string, entry: ThreadDetailEntry): void { | ||
| 197 | const retention = config.retention; | ||
| 198 | clearEntryEviction(entry); | ||
| 199 | |||
| 200 | if (!retention) { | ||
| 201 | disposeEntry(targetKey); | ||
| 202 | return; | ||
| 203 | } | ||
| 204 | |||
| 205 | if (retention.idleTtlMs <= 0) { | ||
| 206 | disposeEntry(targetKey); | ||
| 207 | return; | ||
| 208 | } | ||
| 209 | |||
| 210 | entry.evictionFiber = Effect.runFork( | ||
| 211 | Effect.sleep(Duration.millis(retention.idleTtlMs)).pipe( | ||
| 212 | Effect.andThen( | ||
| 213 | Effect.sync(() => { | ||
| 214 | const current = entries.get(targetKey); | ||
| 215 | if (!current) { | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | |||
| 219 | current.evictionFiber = null; | ||
| 220 | if (current.watcherCount > 0 || current.retainCount > 0 || shouldKeepWarm(current)) { | ||
| 221 | return; | ||
| 222 | } | ||
| 223 | |||
| 224 | disposeEntry(targetKey); | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | ), | ||
| 228 | ); | ||
| 229 | } | ||
| 230 | |||
| 231 | function reconcileRetention(targetKey: string): void { | ||
| 232 | const entry = entries.get(targetKey); | ||
| 233 | if (!entry) { | ||
| 234 | return; | ||
| 235 | } | ||
| 236 | |||
| 237 | clearEntryEviction(entry); | ||
| 238 | if (entry.watcherCount > 0 || entry.retainCount > 0 || shouldKeepWarm(entry)) { | ||
| 239 | return; | ||
| 240 | } | ||
| 241 | |||
| 242 | scheduleEviction(targetKey, entry); | ||
| 243 | evictIdleEntriesToCapacity(); | ||
| 244 | } | ||
| 245 | |||
| 246 | function applyStreamItem( | ||
| 247 | targetKey: string, | ||
| 248 | item: OrchestrationThreadStreamItem, | ||
| 249 | threadId: ThreadIdType, | ||
| 250 | ): void { | ||
| 251 | if (item.kind === "snapshot") { | ||
| 252 | setData(targetKey, item.snapshot.thread); | ||
| 253 | return; | ||
| 254 | } | ||
| 255 | |||
| 256 | const current = getSnapshot({ | ||
| 257 | environmentId: entries.get(targetKey)?.target.environmentId ?? null, | ||
| 258 | threadId, | ||
| 259 | }).data; | ||
| 260 | |||
| 261 | if (current === null) { | ||
| 262 | if (item.event.type === "thread.deleted") { | ||
| 263 | setDeleted(targetKey); | ||
| 264 | } | ||
| 265 | return; | ||
| 266 | } | ||
| 267 | |||
| 268 | const result = applyThreadDetailEvent( | ||
| 269 | current, | ||
| 270 | item.event, | ||
| 271 | config.limits ?? DEFAULT_THREAD_DETAIL_LIMITS, | ||
| 272 | ); | ||
| 273 | |||
| 274 | if (result.kind === "updated") { | ||
| 275 | setData(targetKey, result.thread); | ||
| 276 | return; | ||
| 277 | } | ||
| 278 | |||
| 279 | if (result.kind === "deleted") { | ||
| 280 | setDeleted(targetKey); | ||
| 281 | } | ||
| 282 | } | ||
| 283 | |||
| 284 | function subscribeStream( | ||
| 285 | targetKey: string, | ||
| 286 | target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, | ||
| 287 | client: ThreadDetailClient, | ||
| 288 | ): () => void { | ||
| 289 | markPending(targetKey); | ||
| 290 | return client.subscribeThread( | ||
| 291 | { threadId: target.threadId }, | ||
| 292 | (item) => applyStreamItem(targetKey, item, target.threadId), | ||
| 293 | { | ||
| 294 | onResubscribe: () => markPending(targetKey), | ||
| 295 | }, | ||
| 296 | ); | ||
| 297 | } | ||
| 298 | |||
| 299 | function createDynamicSubscription( | ||
| 300 | targetKey: string, | ||
| 301 | target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, | ||
| 302 | ): () => void { | ||
| 303 | let currentIdentity: string | null = null; | ||
| 304 | let currentUnsub = NOOP; | ||
| 305 | |||
| 306 | const sync = () => { | ||
| 307 | const client = config.getClient(target.environmentId); | ||
| 308 | const identity = client | ||
| 309 | ? (config.getClientIdentity?.(target.environmentId) ?? target.environmentId) | ||
| 310 | : null; | ||
| 311 | |||
| 312 | if (!client || identity === null) { | ||
| 313 | if (currentIdentity !== null) { | ||
| 314 | currentUnsub(); | ||
| 315 | currentUnsub = NOOP; | ||
| 316 | currentIdentity = null; | ||
| 317 | } | ||
| 318 | markPending(targetKey); | ||
| 319 | return; | ||
| 320 | } | ||
| 321 | |||
| 322 | if (currentIdentity === identity) { | ||
| 323 | return; | ||
| 324 | } | ||
| 325 | |||
| 326 | currentUnsub(); | ||
| 327 | currentIdentity = identity; | ||
| 328 | currentUnsub = subscribeStream(targetKey, target, client); | ||
| 329 | }; | ||
| 330 | |||
| 331 | const unsubChanges = config.subscribeClientChanges!(sync); | ||
| 332 | sync(); | ||
| 333 | |||
| 334 | return () => { | ||
| 335 | unsubChanges(); | ||
| 336 | currentUnsub(); | ||
| 337 | }; | ||
| 338 | } | ||
| 339 | |||
| 340 | function acquire( | ||
| 341 | target: ThreadDetailTarget, | ||
| 342 | kind: "watcher" | "retain", | ||
| 343 | client?: ThreadDetailClient, | ||
| 344 | ): () => void { | ||
| 345 | const targetKey = getThreadDetailTargetKey(target); | ||
| 346 | if (targetKey === null || target.environmentId === null || target.threadId === null) { | ||
| 347 | return NOOP; | ||
| 348 | } | ||
| 349 | |||
| 350 | const existing = entries.get(targetKey); | ||
| 351 | if (existing) { | ||
| 352 | clearEntryEviction(existing); | ||
| 353 | existing.lastAccessedAt = nowMs(); | ||
| 354 | if (kind === "watcher") { | ||
| 355 | existing.watcherCount += 1; | ||
| 356 | } else { | ||
| 357 | existing.retainCount += 1; | ||
| 358 | } | ||
| 359 | return () => release(targetKey, kind); | ||
| 360 | } | ||
| 361 | |||
| 362 | let teardown: () => void; | ||
| 363 | const resolvedTarget = { | ||
| 364 | environmentId: target.environmentId, | ||
| 365 | threadId: target.threadId, | ||
| 366 | }; | ||
| 367 | |||
| 368 | if (client) { | ||
| 369 | teardown = subscribeStream(targetKey, resolvedTarget, client); | ||
| 370 | } else if (config.subscribeClientChanges) { | ||
| 371 | teardown = createDynamicSubscription(targetKey, resolvedTarget); | ||
| 372 | } else { | ||
| 373 | const resolved = config.getClient(target.environmentId); | ||
| 374 | if (!resolved) { | ||
| 375 | return NOOP; | ||
| 376 | } | ||
| 377 | teardown = subscribeStream(targetKey, resolvedTarget, resolved); | ||
| 378 | } | ||
| 379 | |||
| 380 | entries.set(targetKey, { | ||
| 381 | target: resolvedTarget, | ||
| 382 | watcherCount: kind === "watcher" ? 1 : 0, | ||
| 383 | retainCount: kind === "retain" ? 1 : 0, | ||
| 384 | teardown, | ||
| 385 | lastAccessedAt: nowMs(), | ||
| 386 | evictionFiber: null, | ||
| 387 | }); | ||
| 388 | evictIdleEntriesToCapacity(); | ||
| 389 | return () => release(targetKey, kind); | ||
| 390 | } | ||
| 391 | |||
| 392 | function release(targetKey: string, kind: "watcher" | "retain"): void { | ||
| 393 | const entry = entries.get(targetKey); | ||
| 394 | if (!entry) { | ||
| 395 | return; | ||
| 396 | } | ||
| 397 | |||
| 398 | if (kind === "watcher") { | ||
| 399 | entry.watcherCount = Math.max(0, entry.watcherCount - 1); | ||
| 400 | } else { | ||
| 401 | entry.retainCount = Math.max(0, entry.retainCount - 1); | ||
| 402 | } | ||
| 403 | entry.lastAccessedAt = nowMs(); | ||
| 404 | reconcileRetention(targetKey); | ||
| 405 | } | ||
| 406 | |||
| 407 | function watch(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void { | ||
| 408 | return acquire(target, "watcher", client); | ||
| 409 | } | ||
| 410 | |||
| 411 | function retain(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void { | ||
| 412 | return acquire(target, "retain", client); | ||
| 413 | } | ||
| 414 | |||
| 415 | function invalidate(target?: ThreadDetailTarget): void { | ||
| 416 | if (target) { | ||
| 417 | const targetKey = getThreadDetailTargetKey(target); | ||
| 418 | if (targetKey !== null) { | ||
| 419 | disposeEntry(targetKey); | ||
| 420 | config.getRegistry().set(threadDetailStateAtom(targetKey), EMPTY_THREAD_DETAIL_STATE); | ||
| 421 | } | ||
| 422 | return; | ||
| 423 | } | ||
| 424 | |||
| 425 | for (const targetKey of entries.keys()) { | ||
| 426 | disposeEntry(targetKey); | ||
| 427 | } | ||
| 428 | for (const key of knownThreadDetailKeys) { | ||
| 429 | config.getRegistry().set(threadDetailStateAtom(key), EMPTY_THREAD_DETAIL_STATE); | ||
| 430 | } | ||
| 431 | } | ||
| 432 | |||
| 433 | function reset(): void { | ||
| 434 | invalidate(); | ||
| 435 | } | ||
| 436 | |||
| 437 | return { | ||
| 438 | watch, | ||
| 439 | retain, | ||
| 440 | getSnapshot, | ||
| 441 | invalidate, | ||
| 442 | reset, | ||
| 443 | }; | ||
| 444 | } | ||
packages/client-runtime/src/transportError.test.ts created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transportError.ts"; | ||
| 4 | |||
| 5 | describe("isTransportConnectionErrorMessage", () => { | ||
| 6 | it("returns true for SocketCloseError", () => { | ||
| 7 | expect(isTransportConnectionErrorMessage("SocketCloseError: connection reset")).toBe(true); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("returns true for SocketOpenError", () => { | ||
| 11 | expect(isTransportConnectionErrorMessage("SocketOpenError: ECONNREFUSED")).toBe(true); | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("returns true for the T3 server WebSocket message", () => { | ||
| 15 | expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( | ||
| 16 | true, | ||
| 17 | ); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("returns true for ping timeout", () => { | ||
| 21 | expect(isTransportConnectionErrorMessage("ping timeout")).toBe(true); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("returns false for business logic errors", () => { | ||
| 25 | expect(isTransportConnectionErrorMessage("Thread not found")).toBe(false); | ||
| 26 | expect(isTransportConnectionErrorMessage("Invalid model selection")).toBe(false); | ||
| 27 | }); | ||
| 28 | |||
| 29 | it("returns false for null, undefined, and empty strings", () => { | ||
| 30 | expect(isTransportConnectionErrorMessage(null)).toBe(false); | ||
| 31 | expect(isTransportConnectionErrorMessage(undefined)).toBe(false); | ||
| 32 | expect(isTransportConnectionErrorMessage("")).toBe(false); | ||
| 33 | expect(isTransportConnectionErrorMessage(" ")).toBe(false); | ||
| 34 | }); | ||
| 35 | }); | ||
| 36 | |||
| 37 | describe("sanitizeThreadErrorMessage", () => { | ||
| 38 | it("strips transport errors", () => { | ||
| 39 | expect(sanitizeThreadErrorMessage("SocketCloseError: oops")).toBeNull(); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("preserves non-transport errors", () => { | ||
| 43 | expect(sanitizeThreadErrorMessage("Thread not found")).toBe("Thread not found"); | ||
| 44 | expect(sanitizeThreadErrorMessage("Select a base branch before sending.")).toBe( | ||
| 45 | "Select a base branch before sending.", | ||
| 46 | ); | ||
| 47 | }); | ||
| 48 | |||
| 49 | it("returns null for null/undefined", () => { | ||
| 50 | expect(sanitizeThreadErrorMessage(null)).toBeNull(); | ||
| 51 | expect(sanitizeThreadErrorMessage(undefined)).toBeNull(); | ||
| 52 | }); | ||
| 53 | }); | ||
packages/client-runtime/src/transportError.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | const TRANSPORT_ERROR_PATTERNS = [ | ||
| 2 | /\bSocketCloseError\b/i, | ||
| 3 | /\bSocketOpenError\b/i, | ||
| 4 | /Unable to connect to the T3 server WebSocket\./i, | ||
| 5 | /\bping timeout\b/i, | ||
| 6 | ] as const; | ||
| 7 | |||
| 8 | /** | ||
| 9 | * Test whether an error message originates from a transport-level connection | ||
| 10 | * failure (socket close, socket open, ping timeout, etc.) rather than a | ||
| 11 | * business-logic error. | ||
| 12 | */ | ||
| 13 | export function isTransportConnectionErrorMessage(message: string | null | undefined): boolean { | ||
| 14 | if (typeof message !== "string") { | ||
| 15 | return false; | ||
| 16 | } | ||
| 17 | |||
| 18 | const normalizedMessage = message.trim(); | ||
| 19 | if (normalizedMessage.length === 0) { | ||
| 20 | return false; | ||
| 21 | } | ||
| 22 | |||
| 23 | return TRANSPORT_ERROR_PATTERNS.some((pattern) => pattern.test(normalizedMessage)); | ||
| 24 | } | ||
| 25 | |||
| 26 | /** | ||
| 27 | * Strip transport connection errors from user-facing error messages. | ||
| 28 | * Returns `null` for transport errors so the UI can distinguish between | ||
| 29 | * real errors and transient connection issues. | ||
| 30 | */ | ||
| 31 | export function sanitizeThreadErrorMessage(message: string | null | undefined): string | null { | ||
| 32 | return isTransportConnectionErrorMessage(message) ? null : (message ?? null); | ||
| 33 | } | ||
packages/client-runtime/src/vcsActionState.test.ts created+292| ... | @@ -0,0 +1,292 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentId, | ||
| 3 | type GitActionProgressEvent, | ||
| 4 | type GitRunStackedActionResult, | ||
| 5 | type VcsCreateRefResult, | ||
| 6 | type VcsCreateWorktreeResult, | ||
| 7 | type VcsPullResult, | ||
| 8 | type VcsStatusResult, | ||
| 9 | type VcsSwitchRefResult, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 12 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | type VcsActionClient, | ||
| 16 | createVcsActionManager, | ||
| 17 | EMPTY_VCS_ACTION_STATE, | ||
| 18 | } from "./vcsActionState.ts"; | ||
| 19 | |||
| 20 | let atomRegistry = AtomRegistry.make(); | ||
| 21 | |||
| 22 | function resetAtomRegistry() { | ||
| 23 | atomRegistry.dispose(); | ||
| 24 | atomRegistry = AtomRegistry.make(); | ||
| 25 | } | ||
| 26 | |||
| 27 | function createDeferred<T>() { | ||
| 28 | let resolve!: (value: T) => void; | ||
| 29 | let reject!: (reason?: unknown) => void; | ||
| 30 | const promise = new Promise<T>((res, rej) => { | ||
| 31 | resolve = res; | ||
| 32 | reject = rej; | ||
| 33 | }); | ||
| 34 | return { promise, resolve, reject }; | ||
| 35 | } | ||
| 36 | |||
| 37 | const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; | ||
| 38 | |||
| 39 | const BASE_STATUS: VcsStatusResult = { | ||
| 40 | isRepo: true, | ||
| 41 | hasPrimaryRemote: true, | ||
| 42 | isDefaultRef: false, | ||
| 43 | refName: "feature/test", | ||
| 44 | hasWorkingTreeChanges: true, | ||
| 45 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 46 | hasUpstream: true, | ||
| 47 | aheadCount: 0, | ||
| 48 | behindCount: 0, | ||
| 49 | pr: null, | ||
| 50 | }; | ||
| 51 | |||
| 52 | function createPhaseStartedEvent(): Extract<GitActionProgressEvent, { kind: "phase_started" }> { | ||
| 53 | return { | ||
| 54 | actionId: "action-123", | ||
| 55 | cwd: "/repo", | ||
| 56 | action: "commit_push", | ||
| 57 | kind: "phase_started", | ||
| 58 | phase: "commit", | ||
| 59 | label: "Committing...", | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | |||
| 63 | function createHookStartedEvent(): Extract<GitActionProgressEvent, { kind: "hook_started" }> { | ||
| 64 | return { | ||
| 65 | actionId: "action-123", | ||
| 66 | cwd: "/repo", | ||
| 67 | action: "commit_push", | ||
| 68 | kind: "hook_started", | ||
| 69 | hookName: "post-commit", | ||
| 70 | }; | ||
| 71 | } | ||
| 72 | |||
| 73 | function createHookOutputEvent(): Extract<GitActionProgressEvent, { kind: "hook_output" }> { | ||
| 74 | return { | ||
| 75 | actionId: "action-123", | ||
| 76 | cwd: "/repo", | ||
| 77 | action: "commit_push", | ||
| 78 | kind: "hook_output", | ||
| 79 | hookName: "post-commit", | ||
| 80 | stream: "stdout", | ||
| 81 | text: "hook output", | ||
| 82 | }; | ||
| 83 | } | ||
| 84 | |||
| 85 | function createHookFinishedEvent(): Extract<GitActionProgressEvent, { kind: "hook_finished" }> { | ||
| 86 | return { | ||
| 87 | actionId: "action-123", | ||
| 88 | cwd: "/repo", | ||
| 89 | action: "commit_push", | ||
| 90 | kind: "hook_finished", | ||
| 91 | hookName: "post-commit", | ||
| 92 | exitCode: 0, | ||
| 93 | durationMs: 12, | ||
| 94 | }; | ||
| 95 | } | ||
| 96 | |||
| 97 | function createActionFinishedEvent(): Extract<GitActionProgressEvent, { kind: "action_finished" }> { | ||
| 98 | return { | ||
| 99 | actionId: "action-123", | ||
| 100 | cwd: "/repo", | ||
| 101 | action: "commit_push", | ||
| 102 | kind: "action_finished", | ||
| 103 | result: { | ||
| 104 | action: "commit_push", | ||
| 105 | branch: { status: "skipped_not_requested" }, | ||
| 106 | commit: { status: "created", commitSha: "abc123", subject: "Test commit" }, | ||
| 107 | push: { | ||
| 108 | status: "pushed", | ||
| 109 | branch: "feature/test", | ||
| 110 | upstreamBranch: "origin/feature/test", | ||
| 111 | }, | ||
| 112 | pr: { status: "skipped_not_requested" }, | ||
| 113 | toast: { | ||
| 114 | title: "Done", | ||
| 115 | description: "Action finished", | ||
| 116 | cta: { kind: "none" }, | ||
| 117 | }, | ||
| 118 | } satisfies GitRunStackedActionResult, | ||
| 119 | }; | ||
| 120 | } | ||
| 121 | |||
| 122 | function createMockClient() { | ||
| 123 | const refreshDeferred = createDeferred<VcsStatusResult>(); | ||
| 124 | const pullDeferred = createDeferred<VcsPullResult>(); | ||
| 125 | const switchRefDeferred = createDeferred<VcsSwitchRefResult>(); | ||
| 126 | const createRefDeferred = createDeferred<VcsCreateRefResult>(); | ||
| 127 | const createWorktreeDeferred = createDeferred<VcsCreateWorktreeResult>(); | ||
| 128 | const initDeferred = createDeferred<void>(); | ||
| 129 | const runChangeRequestDeferred = createDeferred<GitRunStackedActionResult>(); | ||
| 130 | let runChangeRequestProgressListener: ((event: GitActionProgressEvent) => void) | null = null; | ||
| 131 | |||
| 132 | const client: VcsActionClient = { | ||
| 133 | refreshStatus: vi.fn(() => refreshDeferred.promise), | ||
| 134 | pull: vi.fn(() => pullDeferred.promise), | ||
| 135 | switchRef: vi.fn(() => switchRefDeferred.promise), | ||
| 136 | createRef: vi.fn(() => createRefDeferred.promise), | ||
| 137 | createWorktree: vi.fn(() => createWorktreeDeferred.promise), | ||
| 138 | init: vi.fn(() => initDeferred.promise), | ||
| 139 | runChangeRequest: vi.fn((_, options) => { | ||
| 140 | runChangeRequestProgressListener = options?.onProgress ?? null; | ||
| 141 | return runChangeRequestDeferred.promise; | ||
| 142 | }), | ||
| 143 | }; | ||
| 144 | |||
| 145 | return { | ||
| 146 | client, | ||
| 147 | refreshDeferred, | ||
| 148 | pullDeferred, | ||
| 149 | switchRefDeferred, | ||
| 150 | createRefDeferred, | ||
| 151 | createWorktreeDeferred, | ||
| 152 | initDeferred, | ||
| 153 | runChangeRequestDeferred, | ||
| 154 | emitProgress(event: GitActionProgressEvent) { | ||
| 155 | runChangeRequestProgressListener?.(event); | ||
| 156 | }, | ||
| 157 | }; | ||
| 158 | } | ||
| 159 | |||
| 160 | describe("createVcsActionManager", () => { | ||
| 161 | afterEach(() => { | ||
| 162 | resetAtomRegistry(); | ||
| 163 | }); | ||
| 164 | |||
| 165 | it("tracks refreshStatus progress and clears state on success", async () => { | ||
| 166 | const mock = createMockClient(); | ||
| 167 | const manager = createVcsActionManager({ | ||
| 168 | getRegistry: () => atomRegistry, | ||
| 169 | getClient: () => mock.client, | ||
| 170 | }); | ||
| 171 | |||
| 172 | const promise = manager.refreshStatus(TARGET, mock.client); | ||
| 173 | |||
| 174 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 175 | isRunning: true, | ||
| 176 | operation: "refresh_status", | ||
| 177 | currentLabel: "Refreshing source control status", | ||
| 178 | error: null, | ||
| 179 | }); | ||
| 180 | |||
| 181 | mock.refreshDeferred.resolve(BASE_STATUS); | ||
| 182 | |||
| 183 | await expect(promise).resolves.toEqual(BASE_STATUS); | ||
| 184 | expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("tracks runChangeRequest progress events", async () => { | ||
| 188 | const mock = createMockClient(); | ||
| 189 | const onProgress = vi.fn(); | ||
| 190 | const manager = createVcsActionManager({ | ||
| 191 | getRegistry: () => atomRegistry, | ||
| 192 | getClient: () => mock.client, | ||
| 193 | getActionId: () => "action-123", | ||
| 194 | }); | ||
| 195 | |||
| 196 | const promise = manager.runChangeRequest( | ||
| 197 | TARGET, | ||
| 198 | { action: "commit_push", commitMessage: "Test commit" }, | ||
| 199 | { client: mock.client, gitStatus: BASE_STATUS, onProgress }, | ||
| 200 | ); | ||
| 201 | |||
| 202 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 203 | isRunning: true, | ||
| 204 | operation: "run_change_request", | ||
| 205 | actionId: "action-123", | ||
| 206 | currentLabel: "Committing...", | ||
| 207 | error: null, | ||
| 208 | }); | ||
| 209 | |||
| 210 | mock.emitProgress(createPhaseStartedEvent()); | ||
| 211 | expect(manager.getSnapshot(TARGET).currentLabel).toBe("Committing..."); | ||
| 212 | expect(onProgress).toHaveBeenLastCalledWith(createPhaseStartedEvent()); | ||
| 213 | |||
| 214 | mock.emitProgress(createHookStartedEvent()); | ||
| 215 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 216 | currentLabel: "Running post-commit...", | ||
| 217 | hookName: "post-commit", | ||
| 218 | isRunning: true, | ||
| 219 | }); | ||
| 220 | |||
| 221 | mock.emitProgress(createHookOutputEvent()); | ||
| 222 | expect(manager.getSnapshot(TARGET).lastOutputLine).toBe("hook output"); | ||
| 223 | |||
| 224 | mock.emitProgress(createHookFinishedEvent()); | ||
| 225 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 226 | currentLabel: "Committing...", | ||
| 227 | hookName: null, | ||
| 228 | lastOutputLine: null, | ||
| 229 | }); | ||
| 230 | |||
| 231 | const result = createActionFinishedEvent().result; | ||
| 232 | mock.runChangeRequestDeferred.resolve(result); | ||
| 233 | |||
| 234 | await expect(promise).resolves.toEqual(result); | ||
| 235 | expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); | ||
| 236 | }); | ||
| 237 | |||
| 238 | it("stores the error when an operation fails", async () => { | ||
| 239 | const mock = createMockClient(); | ||
| 240 | const manager = createVcsActionManager({ | ||
| 241 | getRegistry: () => atomRegistry, | ||
| 242 | getClient: () => mock.client, | ||
| 243 | }); | ||
| 244 | |||
| 245 | const promise = manager.pull(TARGET, mock.client); | ||
| 246 | |||
| 247 | mock.pullDeferred.reject(new Error("Pull failed.")); | ||
| 248 | |||
| 249 | await expect(promise).rejects.toThrow("Pull failed."); | ||
| 250 | expect(manager.getSnapshot(TARGET)).toMatchObject({ | ||
| 251 | isRunning: false, | ||
| 252 | operation: "pull", | ||
| 253 | currentLabel: null, | ||
| 254 | error: "Pull failed.", | ||
| 255 | }); | ||
| 256 | }); | ||
| 257 | |||
| 258 | it("invalidates after successful mutations but not refreshStatus", async () => { | ||
| 259 | const mock = createMockClient(); | ||
| 260 | const onInvalidate = vi.fn(); | ||
| 261 | const manager = createVcsActionManager({ | ||
| 262 | getRegistry: () => atomRegistry, | ||
| 263 | getClient: () => mock.client, | ||
| 264 | onInvalidate, | ||
| 265 | }); | ||
| 266 | |||
| 267 | const refreshPromise = manager.refreshStatus(TARGET, mock.client); | ||
| 268 | mock.refreshDeferred.resolve(BASE_STATUS); | ||
| 269 | await expect(refreshPromise).resolves.toEqual(BASE_STATUS); | ||
| 270 | expect(onInvalidate).not.toHaveBeenCalled(); | ||
| 271 | |||
| 272 | const pullPromise = manager.pull(TARGET, mock.client); | ||
| 273 | const pullResult: VcsPullResult = { | ||
| 274 | status: "skipped_up_to_date", | ||
| 275 | refName: "main", | ||
| 276 | upstreamRef: null, | ||
| 277 | }; | ||
| 278 | mock.pullDeferred.resolve(pullResult); | ||
| 279 | await expect(pullPromise).resolves.toEqual(pullResult); | ||
| 280 | expect(onInvalidate).toHaveBeenCalledWith(TARGET); | ||
| 281 | }); | ||
| 282 | |||
| 283 | it("returns null when no client is available", async () => { | ||
| 284 | const manager = createVcsActionManager({ | ||
| 285 | getRegistry: () => atomRegistry, | ||
| 286 | getClient: () => null, | ||
| 287 | }); | ||
| 288 | |||
| 289 | await expect(manager.switchRef(TARGET, { refName: "main" })).resolves.toBeNull(); | ||
| 290 | expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); | ||
| 291 | }); | ||
| 292 | }); | ||
packages/client-runtime/src/vcsActionState.ts created+458| ... | @@ -0,0 +1,458 @@ | ||
| 1 | import type { | ||
| 2 | GitActionProgressEvent, | ||
| 3 | GitRunStackedActionInput, | ||
| 4 | GitRunStackedActionResult, | ||
| 5 | GitStackedAction, | ||
| 6 | EnvironmentId, | ||
| 7 | VcsCreateRefInput, | ||
| 8 | VcsCreateRefResult, | ||
| 9 | VcsCreateWorktreeInput, | ||
| 10 | VcsCreateWorktreeResult, | ||
| 11 | VcsPullInput, | ||
| 12 | VcsPullResult, | ||
| 13 | VcsStatusResult, | ||
| 14 | VcsSwitchRefInput, | ||
| 15 | VcsSwitchRefResult, | ||
| 16 | } from "@t3tools/contracts"; | ||
| 17 | import * as DateTime from "effect/DateTime"; | ||
| 18 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 19 | |||
| 20 | import { buildGitActionProgressStages } from "./gitActions.ts"; | ||
| 21 | import type { WsRpcClient } from "./wsRpcClient.ts"; | ||
| 22 | |||
| 23 | export type VcsActionOperation = | ||
| 24 | | "refresh_status" | ||
| 25 | | "run_change_request" | ||
| 26 | | "pull" | ||
| 27 | | "switch_ref" | ||
| 28 | | "create_ref" | ||
| 29 | | "create_worktree" | ||
| 30 | | "init"; | ||
| 31 | |||
| 32 | export interface VcsActionState { | ||
| 33 | readonly isRunning: boolean; | ||
| 34 | readonly operation: VcsActionOperation | null; | ||
| 35 | readonly actionId: string | null; | ||
| 36 | readonly action: GitStackedAction | null; | ||
| 37 | readonly currentLabel: string | null; | ||
| 38 | readonly currentPhaseLabel: string | null; | ||
| 39 | readonly hookName: string | null; | ||
| 40 | readonly lastOutputLine: string | null; | ||
| 41 | readonly phaseStartedAtMs: number | null; | ||
| 42 | readonly hookStartedAtMs: number | null; | ||
| 43 | readonly error: string | null; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface VcsActionTarget { | ||
| 47 | readonly environmentId: EnvironmentId | null; | ||
| 48 | readonly cwd: string | null; | ||
| 49 | } | ||
| 50 | |||
| 51 | export type VcsActionClient = Pick< | ||
| 52 | WsRpcClient["vcs"], | ||
| 53 | "refreshStatus" | "pull" | "switchRef" | "createRef" | "createWorktree" | "init" | ||
| 54 | > & { | ||
| 55 | readonly runChangeRequest: WsRpcClient["git"]["runStackedAction"]; | ||
| 56 | }; | ||
| 57 | |||
| 58 | export const EMPTY_VCS_ACTION_STATE = Object.freeze<VcsActionState>({ | ||
| 59 | isRunning: false, | ||
| 60 | operation: null, | ||
| 61 | actionId: null, | ||
| 62 | action: null, | ||
| 63 | currentLabel: null, | ||
| 64 | currentPhaseLabel: null, | ||
| 65 | hookName: null, | ||
| 66 | lastOutputLine: null, | ||
| 67 | phaseStartedAtMs: null, | ||
| 68 | hookStartedAtMs: null, | ||
| 69 | error: null, | ||
| 70 | }); | ||
| 71 | |||
| 72 | const knownVcsActionKeys = new Set<string>(); | ||
| 73 | let nextGeneratedActionId = 0; | ||
| 74 | const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); | ||
| 75 | |||
| 76 | export const vcsActionStateAtom = Atom.family((key: string) => { | ||
| 77 | knownVcsActionKeys.add(key); | ||
| 78 | return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( | ||
| 79 | Atom.keepAlive, | ||
| 80 | Atom.withLabel(`vcs-action:${key}`), | ||
| 81 | ); | ||
| 82 | }); | ||
| 83 | |||
| 84 | export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( | ||
| 85 | Atom.keepAlive, | ||
| 86 | Atom.withLabel("vcs-action:null"), | ||
| 87 | ); | ||
| 88 | |||
| 89 | export function getVcsActionTargetKey(target: VcsActionTarget): string | null { | ||
| 90 | if (target.environmentId === null || target.cwd === null) { | ||
| 91 | return null; | ||
| 92 | } | ||
| 93 | return `${target.environmentId}:${target.cwd}`; | ||
| 94 | } | ||
| 95 | |||
| 96 | export function applyVcsActionProgressEvent( | ||
| 97 | current: VcsActionState, | ||
| 98 | event: GitActionProgressEvent, | ||
| 99 | ): VcsActionState { | ||
| 100 | const now = nowMs(); | ||
| 101 | |||
| 102 | switch (event.kind) { | ||
| 103 | case "action_started": | ||
| 104 | return { | ||
| 105 | ...current, | ||
| 106 | isRunning: true, | ||
| 107 | actionId: event.actionId, | ||
| 108 | action: event.action, | ||
| 109 | operation: "run_change_request", | ||
| 110 | phaseStartedAtMs: now, | ||
| 111 | hookStartedAtMs: null, | ||
| 112 | hookName: null, | ||
| 113 | lastOutputLine: null, | ||
| 114 | error: null, | ||
| 115 | }; | ||
| 116 | case "phase_started": | ||
| 117 | return { | ||
| 118 | ...current, | ||
| 119 | isRunning: true, | ||
| 120 | actionId: event.actionId, | ||
| 121 | action: event.action, | ||
| 122 | operation: "run_change_request", | ||
| 123 | currentLabel: event.label, | ||
| 124 | currentPhaseLabel: event.label, | ||
| 125 | phaseStartedAtMs: now, | ||
| 126 | hookStartedAtMs: null, | ||
| 127 | hookName: null, | ||
| 128 | lastOutputLine: null, | ||
| 129 | error: null, | ||
| 130 | }; | ||
| 131 | case "hook_started": | ||
| 132 | return { | ||
| 133 | ...current, | ||
| 134 | isRunning: true, | ||
| 135 | actionId: event.actionId, | ||
| 136 | action: event.action, | ||
| 137 | operation: "run_change_request", | ||
| 138 | currentLabel: `Running ${event.hookName}...`, | ||
| 139 | hookName: event.hookName, | ||
| 140 | hookStartedAtMs: now, | ||
| 141 | lastOutputLine: null, | ||
| 142 | error: null, | ||
| 143 | }; | ||
| 144 | case "hook_output": | ||
| 145 | return { | ||
| 146 | ...current, | ||
| 147 | isRunning: true, | ||
| 148 | actionId: event.actionId, | ||
| 149 | action: event.action, | ||
| 150 | operation: "run_change_request", | ||
| 151 | lastOutputLine: event.text, | ||
| 152 | error: null, | ||
| 153 | }; | ||
| 154 | case "hook_finished": | ||
| 155 | return { | ||
| 156 | ...current, | ||
| 157 | isRunning: true, | ||
| 158 | actionId: event.actionId, | ||
| 159 | action: event.action, | ||
| 160 | operation: "run_change_request", | ||
| 161 | currentLabel: current.currentPhaseLabel, | ||
| 162 | hookName: null, | ||
| 163 | hookStartedAtMs: null, | ||
| 164 | lastOutputLine: null, | ||
| 165 | error: null, | ||
| 166 | }; | ||
| 167 | case "action_finished": | ||
| 168 | return { | ||
| 169 | ...current, | ||
| 170 | isRunning: false, | ||
| 171 | actionId: event.actionId, | ||
| 172 | action: event.action, | ||
| 173 | operation: "run_change_request", | ||
| 174 | error: null, | ||
| 175 | }; | ||
| 176 | case "action_failed": | ||
| 177 | return { | ||
| 178 | ...EMPTY_VCS_ACTION_STATE, | ||
| 179 | actionId: event.actionId, | ||
| 180 | action: event.action, | ||
| 181 | operation: "run_change_request", | ||
| 182 | error: event.message, | ||
| 183 | }; | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 187 | export interface VcsActionManagerConfig { | ||
| 188 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 189 | readonly getClient: (environmentId: EnvironmentId) => VcsActionClient | null; | ||
| 190 | readonly getActionId?: () => string; | ||
| 191 | readonly onInvalidate?: (target: VcsActionTarget) => void | Promise<void>; | ||
| 192 | } | ||
| 193 | |||
| 194 | export function createVcsActionManager(config: VcsActionManagerConfig) { | ||
| 195 | function setState(targetKey: string, nextState: VcsActionState): void { | ||
| 196 | config.getRegistry().set(vcsActionStateAtom(targetKey), nextState); | ||
| 197 | } | ||
| 198 | |||
| 199 | function startOperation( | ||
| 200 | targetKey: string, | ||
| 201 | input: { | ||
| 202 | readonly operation: VcsActionOperation; | ||
| 203 | readonly actionId?: string; | ||
| 204 | readonly action?: GitStackedAction; | ||
| 205 | readonly label: string; | ||
| 206 | }, | ||
| 207 | ): void { | ||
| 208 | setState(targetKey, { | ||
| 209 | isRunning: true, | ||
| 210 | operation: input.operation, | ||
| 211 | actionId: input.actionId ?? null, | ||
| 212 | action: input.action ?? null, | ||
| 213 | currentLabel: input.label, | ||
| 214 | currentPhaseLabel: input.label, | ||
| 215 | hookName: null, | ||
| 216 | lastOutputLine: null, | ||
| 217 | phaseStartedAtMs: nowMs(), | ||
| 218 | hookStartedAtMs: null, | ||
| 219 | error: null, | ||
| 220 | }); | ||
| 221 | } | ||
| 222 | |||
| 223 | function finishOperation(targetKey: string): void { | ||
| 224 | setState(targetKey, EMPTY_VCS_ACTION_STATE); | ||
| 225 | } | ||
| 226 | |||
| 227 | function failOperation( | ||
| 228 | targetKey: string, | ||
| 229 | error: unknown, | ||
| 230 | input: { | ||
| 231 | readonly operation: VcsActionOperation; | ||
| 232 | readonly actionId?: string; | ||
| 233 | readonly action?: GitStackedAction; | ||
| 234 | }, | ||
| 235 | ): void { | ||
| 236 | setState(targetKey, { | ||
| 237 | ...EMPTY_VCS_ACTION_STATE, | ||
| 238 | operation: input.operation, | ||
| 239 | actionId: input.actionId ?? null, | ||
| 240 | action: input.action ?? null, | ||
| 241 | error: error instanceof Error ? error.message : "Source control action failed.", | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | |||
| 245 | async function runOperation<TResult>( | ||
| 246 | target: VcsActionTarget, | ||
| 247 | input: { | ||
| 248 | readonly operation: VcsActionOperation; | ||
| 249 | readonly label: string; | ||
| 250 | readonly actionId?: string; | ||
| 251 | readonly action?: GitStackedAction; | ||
| 252 | readonly client?: VcsActionClient | undefined; | ||
| 253 | readonly invalidateOnSuccess?: boolean; | ||
| 254 | readonly execute: (client: VcsActionClient) => Promise<TResult>; | ||
| 255 | }, | ||
| 256 | ): Promise<TResult | null> { | ||
| 257 | const targetKey = getVcsActionTargetKey(target); | ||
| 258 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 259 | return null; | ||
| 260 | } | ||
| 261 | |||
| 262 | const resolved = input.client ?? config.getClient(target.environmentId); | ||
| 263 | if (!resolved) { | ||
| 264 | return null; | ||
| 265 | } | ||
| 266 | |||
| 267 | startOperation(targetKey, input); | ||
| 268 | try { | ||
| 269 | const result = await input.execute(resolved); | ||
| 270 | finishOperation(targetKey); | ||
| 271 | if (input.invalidateOnSuccess ?? true) { | ||
| 272 | await config.onInvalidate?.(target); | ||
| 273 | } | ||
| 274 | return result; | ||
| 275 | } catch (error) { | ||
| 276 | failOperation(targetKey, error, input); | ||
| 277 | throw error; | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 281 | function getSnapshot(target: VcsActionTarget): VcsActionState { | ||
| 282 | const targetKey = getVcsActionTargetKey(target); | ||
| 283 | if (targetKey === null) { | ||
| 284 | return EMPTY_VCS_ACTION_STATE; | ||
| 285 | } | ||
| 286 | |||
| 287 | return config.getRegistry().get(vcsActionStateAtom(targetKey)); | ||
| 288 | } | ||
| 289 | |||
| 290 | async function refreshStatus( | ||
| 291 | target: VcsActionTarget, | ||
| 292 | client?: VcsActionClient, | ||
| 293 | options?: { readonly quiet?: boolean }, | ||
| 294 | ): Promise<Awaited<ReturnType<VcsActionClient["refreshStatus"]>> | null> { | ||
| 295 | if (options?.quiet) { | ||
| 296 | if (target.environmentId === null || target.cwd === null) { | ||
| 297 | return null; | ||
| 298 | } | ||
| 299 | const resolved = client ?? config.getClient(target.environmentId); | ||
| 300 | return resolved ? resolved.refreshStatus({ cwd: target.cwd }) : null; | ||
| 301 | } | ||
| 302 | |||
| 303 | return runOperation(target, { | ||
| 304 | operation: "refresh_status", | ||
| 305 | label: "Refreshing source control status", | ||
| 306 | client, | ||
| 307 | invalidateOnSuccess: false, | ||
| 308 | execute: (resolved) => resolved.refreshStatus({ cwd: target.cwd! }), | ||
| 309 | }); | ||
| 310 | } | ||
| 311 | |||
| 312 | async function pull( | ||
| 313 | target: VcsActionTarget, | ||
| 314 | client?: VcsActionClient, | ||
| 315 | options?: { readonly label?: string }, | ||
| 316 | ): Promise<VcsPullResult | null> { | ||
| 317 | return runOperation(target, { | ||
| 318 | operation: "pull", | ||
| 319 | label: options?.label ?? "Pulling latest changes", | ||
| 320 | client, | ||
| 321 | execute: (resolved) => resolved.pull({ cwd: target.cwd! } satisfies VcsPullInput), | ||
| 322 | }); | ||
| 323 | } | ||
| 324 | |||
| 325 | async function switchRef( | ||
| 326 | target: VcsActionTarget, | ||
| 327 | input: Omit<VcsSwitchRefInput, "cwd">, | ||
| 328 | client?: VcsActionClient, | ||
| 329 | options?: { readonly label?: string }, | ||
| 330 | ): Promise<VcsSwitchRefResult | null> { | ||
| 331 | return runOperation(target, { | ||
| 332 | operation: "switch_ref", | ||
| 333 | label: options?.label ?? "Switching branch", | ||
| 334 | client, | ||
| 335 | execute: (resolved) => resolved.switchRef({ cwd: target.cwd!, ...input }), | ||
| 336 | }); | ||
| 337 | } | ||
| 338 | |||
| 339 | async function createRef( | ||
| 340 | target: VcsActionTarget, | ||
| 341 | input: Omit<VcsCreateRefInput, "cwd">, | ||
| 342 | client?: VcsActionClient, | ||
| 343 | options?: { readonly label?: string }, | ||
| 344 | ): Promise<VcsCreateRefResult | null> { | ||
| 345 | return runOperation(target, { | ||
| 346 | operation: "create_ref", | ||
| 347 | label: options?.label ?? "Creating branch", | ||
| 348 | client, | ||
| 349 | execute: (resolved) => resolved.createRef({ cwd: target.cwd!, ...input }), | ||
| 350 | }); | ||
| 351 | } | ||
| 352 | |||
| 353 | async function createWorktree( | ||
| 354 | target: VcsActionTarget, | ||
| 355 | input: Omit<VcsCreateWorktreeInput, "cwd">, | ||
| 356 | client?: VcsActionClient, | ||
| 357 | options?: { readonly label?: string }, | ||
| 358 | ): Promise<VcsCreateWorktreeResult | null> { | ||
| 359 | return runOperation(target, { | ||
| 360 | operation: "create_worktree", | ||
| 361 | label: options?.label ?? "Creating worktree", | ||
| 362 | client, | ||
| 363 | execute: (resolved) => resolved.createWorktree({ cwd: target.cwd!, ...input }), | ||
| 364 | }); | ||
| 365 | } | ||
| 366 | |||
| 367 | async function init( | ||
| 368 | target: VcsActionTarget, | ||
| 369 | client?: VcsActionClient, | ||
| 370 | options?: { readonly label?: string }, | ||
| 371 | ): Promise<Awaited<ReturnType<VcsActionClient["init"]>> | null> { | ||
| 372 | return runOperation(target, { | ||
| 373 | operation: "init", | ||
| 374 | label: options?.label ?? "Initializing repository", | ||
| 375 | client, | ||
| 376 | execute: (resolved) => resolved.init({ cwd: target.cwd! }), | ||
| 377 | }); | ||
| 378 | } | ||
| 379 | |||
| 380 | async function runChangeRequest( | ||
| 381 | target: VcsActionTarget, | ||
| 382 | input: Omit<GitRunStackedActionInput, "cwd" | "actionId"> & { readonly actionId?: string }, | ||
| 383 | options?: { | ||
| 384 | readonly client?: VcsActionClient; | ||
| 385 | readonly gitStatus?: VcsStatusResult | null; | ||
| 386 | readonly onProgress?: (event: GitActionProgressEvent) => void; | ||
| 387 | }, | ||
| 388 | ): Promise<GitRunStackedActionResult | null> { | ||
| 389 | const actionId = | ||
| 390 | input.actionId ?? | ||
| 391 | config.getActionId?.() ?? | ||
| 392 | `vcs-action-${nowMs()}-${++nextGeneratedActionId}`; | ||
| 393 | const targetKey = getVcsActionTargetKey(target); | ||
| 394 | |||
| 395 | return runOperation(target, { | ||
| 396 | operation: "run_change_request", | ||
| 397 | label: | ||
| 398 | buildGitActionProgressStages({ | ||
| 399 | action: input.action, | ||
| 400 | hasCustomCommitMessage: Boolean(input.commitMessage?.trim()), | ||
| 401 | hasWorkingTreeChanges: options?.gitStatus?.hasWorkingTreeChanges ?? false, | ||
| 402 | featureBranch: input.featureBranch ?? false, | ||
| 403 | shouldPushBeforePr: | ||
| 404 | input.action === "create_pr" && | ||
| 405 | (!(options?.gitStatus?.hasUpstream ?? false) || | ||
| 406 | (options?.gitStatus?.aheadCount ?? 0) > 0), | ||
| 407 | })[0] ?? "Running source control action", | ||
| 408 | actionId, | ||
| 409 | action: input.action, | ||
| 410 | client: options?.client, | ||
| 411 | execute: async (resolved) => { | ||
| 412 | const result = await resolved.runChangeRequest( | ||
| 413 | { | ||
| 414 | cwd: target.cwd!, | ||
| 415 | actionId, | ||
| 416 | ...input, | ||
| 417 | }, | ||
| 418 | { | ||
| 419 | onProgress: (event) => { | ||
| 420 | if (targetKey !== null) { | ||
| 421 | const current = getSnapshot(target); | ||
| 422 | setState(targetKey, applyVcsActionProgressEvent(current, event)); | ||
| 423 | } | ||
| 424 | options?.onProgress?.(event); | ||
| 425 | }, | ||
| 426 | }, | ||
| 427 | ); | ||
| 428 | return result; | ||
| 429 | }, | ||
| 430 | }); | ||
| 431 | } | ||
| 432 | |||
| 433 | function reset(target?: VcsActionTarget): void { | ||
| 434 | if (target) { | ||
| 435 | const targetKey = getVcsActionTargetKey(target); | ||
| 436 | if (targetKey !== null) { | ||
| 437 | setState(targetKey, EMPTY_VCS_ACTION_STATE); | ||
| 438 | } | ||
| 439 | return; | ||
| 440 | } | ||
| 441 | |||
| 442 | for (const key of knownVcsActionKeys) { | ||
| 443 | setState(key, EMPTY_VCS_ACTION_STATE); | ||
| 444 | } | ||
| 445 | } | ||
| 446 | |||
| 447 | return { | ||
| 448 | getSnapshot, | ||
| 449 | refreshStatus, | ||
| 450 | pull, | ||
| 451 | switchRef, | ||
| 452 | createRef, | ||
| 453 | createWorktree, | ||
| 454 | init, | ||
| 455 | runChangeRequest, | ||
| 456 | reset, | ||
| 457 | }; | ||
| 458 | } | ||
packages/client-runtime/src/vcsRefState.test.ts created+399| ... | @@ -0,0 +1,399 @@ | ||
| 1 | import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; | ||
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | createVcsRefManager, | ||
| 7 | EMPTY_VCS_REF_STATE, | ||
| 8 | vcsRefStateAtom, | ||
| 9 | type VcsRefClient, | ||
| 10 | } from "./vcsRefState.ts"; | ||
| 11 | |||
| 12 | let atomRegistry = AtomRegistry.make(); | ||
| 13 | |||
| 14 | function resetAtomRegistry() { | ||
| 15 | atomRegistry.dispose(); | ||
| 16 | atomRegistry = AtomRegistry.make(); | ||
| 17 | } | ||
| 18 | |||
| 19 | const noop = () => undefined; | ||
| 20 | |||
| 21 | const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; | ||
| 22 | |||
| 23 | const FIRST_PAGE: VcsListRefsResult = { | ||
| 24 | refs: [ | ||
| 25 | { name: "main", current: true, isDefault: true, worktreePath: null }, | ||
| 26 | { name: "feature/a", current: false, isDefault: false, worktreePath: null }, | ||
| 27 | ], | ||
| 28 | isRepo: true, | ||
| 29 | hasPrimaryRemote: true, | ||
| 30 | nextCursor: 2, | ||
| 31 | totalCount: 3, | ||
| 32 | }; | ||
| 33 | |||
| 34 | const SECOND_PAGE: VcsListRefsResult = { | ||
| 35 | refs: [{ name: "feature/b", current: false, isDefault: false, worktreePath: null }], | ||
| 36 | isRepo: true, | ||
| 37 | hasPrimaryRemote: true, | ||
| 38 | nextCursor: null, | ||
| 39 | totalCount: 3, | ||
| 40 | }; | ||
| 41 | |||
| 42 | function createMockClient() { | ||
| 43 | const listRefs = vi.fn(async (input: Parameters<VcsRefClient["listRefs"]>[0]) => { | ||
| 44 | if (input.query === "feature") { | ||
| 45 | return { | ||
| 46 | ...FIRST_PAGE, | ||
| 47 | refs: FIRST_PAGE.refs.filter((branch) => branch.name.includes("feature")), | ||
| 48 | nextCursor: null, | ||
| 49 | totalCount: 2, | ||
| 50 | } satisfies VcsListRefsResult; | ||
| 51 | } | ||
| 52 | |||
| 53 | if (input.cursor === 2) { | ||
| 54 | return SECOND_PAGE; | ||
| 55 | } | ||
| 56 | |||
| 57 | return FIRST_PAGE; | ||
| 58 | }); | ||
| 59 | |||
| 60 | return { | ||
| 61 | client: { listRefs } satisfies VcsRefClient, | ||
| 62 | listRefs, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | function deferred<T>() { | ||
| 67 | let resolve!: (value: T) => void; | ||
| 68 | let reject!: (error: unknown) => void; | ||
| 69 | const promise = new Promise<T>((resolvePromise, rejectPromise) => { | ||
| 70 | resolve = resolvePromise; | ||
| 71 | reject = rejectPromise; | ||
| 72 | }); | ||
| 73 | return { promise, resolve, reject }; | ||
| 74 | } | ||
| 75 | |||
| 76 | describe("createVcsRefManager", () => { | ||
| 77 | afterEach(() => { | ||
| 78 | resetAtomRegistry(); | ||
| 79 | }); | ||
| 80 | |||
| 81 | it("loads the first page and stores it in atom state", async () => { | ||
| 82 | const mock = createMockClient(); | ||
| 83 | const manager = createVcsRefManager({ | ||
| 84 | getRegistry: () => atomRegistry, | ||
| 85 | getClient: () => mock.client, | ||
| 86 | }); | ||
| 87 | |||
| 88 | const promise = manager.load(TARGET, mock.client, { limit: 100 }); | ||
| 89 | |||
| 90 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 91 | data: null, | ||
| 92 | isPending: true, | ||
| 93 | error: null, | ||
| 94 | }); | ||
| 95 | |||
| 96 | await expect(promise).resolves.toEqual(FIRST_PAGE); | ||
| 97 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 98 | data: FIRST_PAGE, | ||
| 99 | isPending: false, | ||
| 100 | error: null, | ||
| 101 | }); | ||
| 102 | expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("loads the next page and appends refs", async () => { | ||
| 106 | const mock = createMockClient(); | ||
| 107 | const manager = createVcsRefManager({ | ||
| 108 | getRegistry: () => atomRegistry, | ||
| 109 | getClient: () => mock.client, | ||
| 110 | }); | ||
| 111 | |||
| 112 | await manager.load(TARGET, mock.client); | ||
| 113 | const next = await manager.loadNext(TARGET, mock.client); | ||
| 114 | |||
| 115 | expect(next).toEqual({ | ||
| 116 | ...SECOND_PAGE, | ||
| 117 | refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs], | ||
| 118 | }); | ||
| 119 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 120 | data: { | ||
| 121 | ...SECOND_PAGE, | ||
| 122 | refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs], | ||
| 123 | }, | ||
| 124 | isPending: false, | ||
| 125 | error: null, | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("keeps cached refs visible while refreshing", async () => { | ||
| 130 | const nextLoad = deferred<VcsListRefsResult>(); | ||
| 131 | let callCount = 0; | ||
| 132 | const listRefs = vi.fn((async () => { | ||
| 133 | callCount += 1; | ||
| 134 | return callCount === 1 ? FIRST_PAGE : nextLoad.promise; | ||
| 135 | }) satisfies VcsRefClient["listRefs"]); | ||
| 136 | const client = { listRefs } satisfies VcsRefClient; | ||
| 137 | const manager = createVcsRefManager({ | ||
| 138 | getRegistry: () => atomRegistry, | ||
| 139 | getClient: () => client, | ||
| 140 | }); | ||
| 141 | |||
| 142 | await manager.load(TARGET, client); | ||
| 143 | |||
| 144 | const refresh = manager.load(TARGET, client); | ||
| 145 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 146 | data: FIRST_PAGE, | ||
| 147 | isPending: true, | ||
| 148 | error: null, | ||
| 149 | }); | ||
| 150 | |||
| 151 | nextLoad.resolve(SECOND_PAGE); | ||
| 152 | await expect(refresh).resolves.toEqual(SECOND_PAGE); | ||
| 153 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 154 | data: SECOND_PAGE, | ||
| 155 | isPending: false, | ||
| 156 | error: null, | ||
| 157 | }); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("preserves loaded pages during first-page revalidation", async () => { | ||
| 161 | const refreshedFirstPage: VcsListRefsResult = { | ||
| 162 | ...FIRST_PAGE, | ||
| 163 | refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }], | ||
| 164 | nextCursor: 1, | ||
| 165 | totalCount: 3, | ||
| 166 | }; | ||
| 167 | let callCount = 0; | ||
| 168 | const listRefs = vi.fn((async (input) => { | ||
| 169 | callCount += 1; | ||
| 170 | if (input.cursor === 2) { | ||
| 171 | return SECOND_PAGE; | ||
| 172 | } | ||
| 173 | return callCount === 1 ? FIRST_PAGE : refreshedFirstPage; | ||
| 174 | }) satisfies VcsRefClient["listRefs"]); | ||
| 175 | const client = { listRefs } satisfies VcsRefClient; | ||
| 176 | const manager = createVcsRefManager({ | ||
| 177 | getRegistry: () => atomRegistry, | ||
| 178 | getClient: () => client, | ||
| 179 | }); | ||
| 180 | |||
| 181 | await manager.load(TARGET, client); | ||
| 182 | await manager.loadNext(TARGET, client); | ||
| 183 | const beforeRefresh = manager.getSnapshot(TARGET).data; | ||
| 184 | expect(beforeRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]); | ||
| 185 | |||
| 186 | await manager.load(TARGET, client, { preserveLoadedRefs: true }); | ||
| 187 | |||
| 188 | const afterRefresh = manager.getSnapshot(TARGET).data; | ||
| 189 | expect(afterRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]); | ||
| 190 | expect(afterRefresh?.nextCursor).toBeNull(); | ||
| 191 | }); | ||
| 192 | |||
| 193 | it("stores query-specific state independently", async () => { | ||
| 194 | const mock = createMockClient(); | ||
| 195 | const manager = createVcsRefManager({ | ||
| 196 | getRegistry: () => atomRegistry, | ||
| 197 | getClient: () => mock.client, | ||
| 198 | }); | ||
| 199 | |||
| 200 | const queriedTarget = { ...TARGET, query: "feature" } as const; | ||
| 201 | const queried = await manager.load(queriedTarget, mock.client); | ||
| 202 | |||
| 203 | expect(queried?.refs.map((branch) => branch.name)).toEqual(["feature/a"]); | ||
| 204 | expect(manager.getSnapshot(TARGET).data).toBeNull(); | ||
| 205 | expect(manager.getSnapshot(queriedTarget).data?.refs.map((branch) => branch.name)).toEqual([ | ||
| 206 | "feature/a", | ||
| 207 | ]); | ||
| 208 | }); | ||
| 209 | |||
| 210 | it("returns cached data when no client is available", async () => { | ||
| 211 | const manager = createVcsRefManager({ | ||
| 212 | getRegistry: () => atomRegistry, | ||
| 213 | getClient: () => null, | ||
| 214 | }); | ||
| 215 | |||
| 216 | atomRegistry.set(vcsRefStateAtom("env-local:/repo:"), { | ||
| 217 | data: FIRST_PAGE, | ||
| 218 | isPending: false, | ||
| 219 | error: null, | ||
| 220 | }); | ||
| 221 | |||
| 222 | await expect(manager.load(TARGET)).resolves.toEqual(FIRST_PAGE); | ||
| 223 | }); | ||
| 224 | |||
| 225 | it("resets state", async () => { | ||
| 226 | const mock = createMockClient(); | ||
| 227 | const manager = createVcsRefManager({ | ||
| 228 | getRegistry: () => atomRegistry, | ||
| 229 | getClient: () => mock.client, | ||
| 230 | }); | ||
| 231 | |||
| 232 | await manager.load(TARGET, mock.client); | ||
| 233 | manager.reset(); | ||
| 234 | |||
| 235 | expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE); | ||
| 236 | }); | ||
| 237 | |||
| 238 | it("invalidates every query for a cwd scope", async () => { | ||
| 239 | const mock = createMockClient(); | ||
| 240 | const manager = createVcsRefManager({ | ||
| 241 | getRegistry: () => atomRegistry, | ||
| 242 | getClient: () => mock.client, | ||
| 243 | }); | ||
| 244 | const queriedTarget = { ...TARGET, query: "feature" } as const; | ||
| 245 | |||
| 246 | await manager.load(TARGET, mock.client); | ||
| 247 | await manager.load(queriedTarget, mock.client); | ||
| 248 | |||
| 249 | manager.invalidateScope({ environmentId: TARGET.environmentId, cwd: TARGET.cwd }); | ||
| 250 | |||
| 251 | expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE); | ||
| 252 | expect(manager.getSnapshot(queriedTarget)).toEqual(EMPTY_VCS_REF_STATE); | ||
| 253 | }); | ||
| 254 | |||
| 255 | it("invalidates target in-flight loads before they can write stale data", async () => { | ||
| 256 | const firstLoad = deferred<VcsListRefsResult>(); | ||
| 257 | let callCount = 0; | ||
| 258 | const listRefs = vi.fn((async () => { | ||
| 259 | callCount += 1; | ||
| 260 | return callCount === 1 ? firstLoad.promise : SECOND_PAGE; | ||
| 261 | }) satisfies VcsRefClient["listRefs"]); | ||
| 262 | const client = { listRefs } satisfies VcsRefClient; | ||
| 263 | const manager = createVcsRefManager({ | ||
| 264 | getRegistry: () => atomRegistry, | ||
| 265 | getClient: () => client, | ||
| 266 | }); | ||
| 267 | |||
| 268 | const staleLoad = manager.load(TARGET, client); | ||
| 269 | manager.invalidate(TARGET); | ||
| 270 | const freshLoad = manager.load(TARGET, client); | ||
| 271 | |||
| 272 | expect(listRefs).toHaveBeenCalledTimes(2); | ||
| 273 | |||
| 274 | firstLoad.resolve(FIRST_PAGE); | ||
| 275 | await expect(staleLoad).resolves.toEqual(FIRST_PAGE); | ||
| 276 | await expect(freshLoad).resolves.toEqual(SECOND_PAGE); | ||
| 277 | expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); | ||
| 278 | }); | ||
| 279 | |||
| 280 | it("watches refs with a ref-counted client-change subscription", async () => { | ||
| 281 | const mock = createMockClient(); | ||
| 282 | let listener: () => void = noop; | ||
| 283 | const unsubscribe = vi.fn(); | ||
| 284 | const manager = createVcsRefManager({ | ||
| 285 | getRegistry: () => atomRegistry, | ||
| 286 | getClient: () => mock.client, | ||
| 287 | subscribeClientChanges: (nextListener) => { | ||
| 288 | listener = nextListener; | ||
| 289 | return unsubscribe; | ||
| 290 | }, | ||
| 291 | watchLimit: 100, | ||
| 292 | }); | ||
| 293 | |||
| 294 | const firstUnwatch = manager.watch(TARGET); | ||
| 295 | const secondUnwatch = manager.watch(TARGET); | ||
| 296 | await Promise.resolve(); | ||
| 297 | |||
| 298 | expect(mock.listRefs).toHaveBeenCalledTimes(1); | ||
| 299 | expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 }); | ||
| 300 | |||
| 301 | listener(); | ||
| 302 | await Promise.resolve(); | ||
| 303 | expect(mock.listRefs).toHaveBeenCalledTimes(1); | ||
| 304 | |||
| 305 | firstUnwatch(); | ||
| 306 | expect(unsubscribe).not.toHaveBeenCalled(); | ||
| 307 | secondUnwatch(); | ||
| 308 | expect(unsubscribe).toHaveBeenCalledTimes(1); | ||
| 309 | }); | ||
| 310 | |||
| 311 | it("skips watched refresh while cached refs are fresh", async () => { | ||
| 312 | const mock = createMockClient(); | ||
| 313 | const manager = createVcsRefManager({ | ||
| 314 | getRegistry: () => atomRegistry, | ||
| 315 | getClient: () => mock.client, | ||
| 316 | staleTimeMs: 60_000, | ||
| 317 | watchLimit: 100, | ||
| 318 | }); | ||
| 319 | |||
| 320 | const firstUnwatch = manager.watch(TARGET); | ||
| 321 | await vi.waitFor(() => { | ||
| 322 | expect(manager.getSnapshot(TARGET).data).toEqual(FIRST_PAGE); | ||
| 323 | }); | ||
| 324 | firstUnwatch(); | ||
| 325 | |||
| 326 | const secondUnwatch = manager.watch(TARGET); | ||
| 327 | await Promise.resolve(); | ||
| 328 | expect(mock.listRefs).toHaveBeenCalledTimes(1); | ||
| 329 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 330 | data: FIRST_PAGE, | ||
| 331 | isPending: false, | ||
| 332 | error: null, | ||
| 333 | }); | ||
| 334 | |||
| 335 | secondUnwatch(); | ||
| 336 | }); | ||
| 337 | |||
| 338 | it("swallows watched refresh failures after storing error state", async () => { | ||
| 339 | const refreshError = new Error("backend unavailable"); | ||
| 340 | const listRefs = vi.fn(async () => { | ||
| 341 | throw refreshError; | ||
| 342 | }); | ||
| 343 | const onBackgroundError = vi.fn(); | ||
| 344 | const manager = createVcsRefManager({ | ||
| 345 | getRegistry: () => atomRegistry, | ||
| 346 | getClient: () => ({ listRefs }), | ||
| 347 | onBackgroundError, | ||
| 348 | }); | ||
| 349 | |||
| 350 | manager.watch(TARGET); | ||
| 351 | await Promise.resolve(); | ||
| 352 | await Promise.resolve(); | ||
| 353 | |||
| 354 | await vi.waitFor(() => { | ||
| 355 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 356 | data: null, | ||
| 357 | isPending: false, | ||
| 358 | error: "backend unavailable", | ||
| 359 | }); | ||
| 360 | expect(onBackgroundError).toHaveBeenCalledWith(refreshError); | ||
| 361 | }); | ||
| 362 | }); | ||
| 363 | |||
| 364 | it("starts a new watched refresh when the client is replaced while a load is in flight", async () => { | ||
| 365 | const firstLoad = deferred<VcsListRefsResult>(); | ||
| 366 | const secondLoad = deferred<VcsListRefsResult>(); | ||
| 367 | const firstListRefs = vi.fn(() => firstLoad.promise); | ||
| 368 | const secondListRefs = vi.fn(() => secondLoad.promise); | ||
| 369 | const firstClient = { listRefs: firstListRefs } satisfies VcsRefClient; | ||
| 370 | const secondClient = { listRefs: secondListRefs } satisfies VcsRefClient; | ||
| 371 | let currentClient: VcsRefClient = firstClient; | ||
| 372 | let listener: () => void = noop; | ||
| 373 | const manager = createVcsRefManager({ | ||
| 374 | getRegistry: () => atomRegistry, | ||
| 375 | getClient: () => currentClient, | ||
| 376 | subscribeClientChanges: (nextListener) => { | ||
| 377 | listener = nextListener; | ||
| 378 | return noop; | ||
| 379 | }, | ||
| 380 | }); | ||
| 381 | |||
| 382 | manager.watch(TARGET); | ||
| 383 | await Promise.resolve(); | ||
| 384 | expect(firstListRefs).toHaveBeenCalledTimes(1); | ||
| 385 | |||
| 386 | currentClient = secondClient; | ||
| 387 | listener(); | ||
| 388 | await Promise.resolve(); | ||
| 389 | expect(secondListRefs).toHaveBeenCalledTimes(1); | ||
| 390 | |||
| 391 | secondLoad.resolve(SECOND_PAGE); | ||
| 392 | await secondLoad.promise; | ||
| 393 | expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); | ||
| 394 | |||
| 395 | firstLoad.resolve(FIRST_PAGE); | ||
| 396 | await firstLoad.promise; | ||
| 397 | expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); | ||
| 398 | }); | ||
| 399 | }); | ||
packages/client-runtime/src/vcsRefState.ts created+451| ... | @@ -0,0 +1,451 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | VcsListRefsInput, | ||
| 4 | VcsListRefsResult, | ||
| 5 | VcsRef as ContractVcsRef, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as Clock from "effect/Clock"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import { Atom, type AtomRegistry, type AsyncResult } from "effect/unstable/reactivity"; | ||
| 10 | |||
| 11 | import type { WsRpcClient } from "./wsRpcClient.ts"; | ||
| 12 | |||
| 13 | export interface VcsRefTarget { | ||
| 14 | readonly environmentId: EnvironmentId | null; | ||
| 15 | readonly cwd: string | null; | ||
| 16 | readonly query?: string | null; | ||
| 17 | } | ||
| 18 | |||
| 19 | export interface VcsRefScope { | ||
| 20 | readonly environmentId: EnvironmentId | null; | ||
| 21 | readonly cwd: string | null; | ||
| 22 | } | ||
| 23 | |||
| 24 | export interface VcsRefState { | ||
| 25 | readonly data: VcsListRefsResult | null; | ||
| 26 | readonly isPending: boolean; | ||
| 27 | readonly error: string | null; | ||
| 28 | } | ||
| 29 | |||
| 30 | export type VcsRef = ContractVcsRef; | ||
| 31 | export type VcsRefClient = Pick<WsRpcClient["vcs"], "listRefs">; | ||
| 32 | |||
| 33 | export const EMPTY_VCS_REF_STATE = Object.freeze<VcsRefState>({ | ||
| 34 | data: null, | ||
| 35 | isPending: false, | ||
| 36 | error: null, | ||
| 37 | }); | ||
| 38 | |||
| 39 | const INITIAL_VCS_REF_STATE = Object.freeze<VcsRefState>({ | ||
| 40 | data: null, | ||
| 41 | isPending: true, | ||
| 42 | error: null, | ||
| 43 | }); | ||
| 44 | |||
| 45 | const knownVcsRefKeys = new Set<string>(); | ||
| 46 | |||
| 47 | export const vcsRefStateAtom = Atom.family((key: string) => { | ||
| 48 | knownVcsRefKeys.add(key); | ||
| 49 | return Atom.make(EMPTY_VCS_REF_STATE).pipe(Atom.keepAlive, Atom.withLabel(`vcs-refs:${key}`)); | ||
| 50 | }); | ||
| 51 | |||
| 52 | export const EMPTY_VCS_REF_ATOM = Atom.make(EMPTY_VCS_REF_STATE).pipe( | ||
| 53 | Atom.keepAlive, | ||
| 54 | Atom.withLabel("vcs-refs:null"), | ||
| 55 | ); | ||
| 56 | |||
| 57 | function normalizeQuery(query: string | null | undefined): string { | ||
| 58 | return query?.trim() ?? ""; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function getVcsRefTargetKey(target: VcsRefTarget): string | null { | ||
| 62 | if (target.environmentId === null || target.cwd === null) { | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | |||
| 66 | return `${target.environmentId}:${target.cwd}:${normalizeQuery(target.query)}`; | ||
| 67 | } | ||
| 68 | |||
| 69 | function toErrorMessage(error: unknown): string { | ||
| 70 | return error instanceof Error ? error.message : "Failed to load refs."; | ||
| 71 | } | ||
| 72 | |||
| 73 | function mergeRefs( | ||
| 74 | previous: ReadonlyArray<VcsRef>, | ||
| 75 | next: ReadonlyArray<VcsRef>, | ||
| 76 | ): ReadonlyArray<VcsRef> { | ||
| 77 | const merged = new Map<string, VcsRef>(); | ||
| 78 | for (const branch of previous) { | ||
| 79 | merged.set(branch.name, branch); | ||
| 80 | } | ||
| 81 | for (const branch of next) { | ||
| 82 | merged.set(branch.name, branch); | ||
| 83 | } | ||
| 84 | return [...merged.values()]; | ||
| 85 | } | ||
| 86 | |||
| 87 | export interface VcsRefManagerConfig { | ||
| 88 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 89 | readonly getClient: (environmentId: EnvironmentId) => VcsRefClient | null; | ||
| 90 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 91 | readonly watchLimit?: number; | ||
| 92 | readonly staleTimeMs?: number; | ||
| 93 | readonly onBackgroundError?: (error: unknown) => void; | ||
| 94 | } | ||
| 95 | |||
| 96 | interface WatchedEntry { | ||
| 97 | refCount: number; | ||
| 98 | teardown: () => void; | ||
| 99 | } | ||
| 100 | |||
| 101 | const NOOP: () => void = () => undefined; | ||
| 102 | |||
| 103 | export function createVcsRefManager(config: VcsRefManagerConfig) { | ||
| 104 | const inFlight = new Map< | ||
| 105 | string, | ||
| 106 | { | ||
| 107 | readonly client: VcsRefClient; | ||
| 108 | readonly promise: Promise<VcsListRefsResult | null>; | ||
| 109 | } | ||
| 110 | >(); | ||
| 111 | const loadVersions = new Map<string, number>(); | ||
| 112 | const watched = new Map<string, WatchedEntry>(); | ||
| 113 | const lastLoadedAt = new Map<string, number>(); | ||
| 114 | const refreshTargets = new Map<string, VcsRefTarget>(); | ||
| 115 | const watchLoadOptions = | ||
| 116 | config.watchLimit === undefined | ||
| 117 | ? undefined | ||
| 118 | : { limit: config.watchLimit, preserveLoadedRefs: true }; | ||
| 119 | |||
| 120 | const watchedRefreshAtom = Atom.family((targetKey: string) => | ||
| 121 | Atom.make(() => | ||
| 122 | Effect.promise(() => { | ||
| 123 | const target = refreshTargets.get(targetKey); | ||
| 124 | return target ? load(target, undefined, watchLoadOptions) : Promise.resolve(null); | ||
| 125 | }), | ||
| 126 | ).pipe( | ||
| 127 | Atom.swr({ | ||
| 128 | staleTime: config.staleTimeMs ?? 0, | ||
| 129 | revalidateOnMount: true, | ||
| 130 | }), | ||
| 131 | Atom.withLabel(`vcs-refs:watched-refresh:${targetKey}`), | ||
| 132 | ), | ||
| 133 | ); | ||
| 134 | |||
| 135 | function getLoadVersion(targetKey: string): number { | ||
| 136 | return loadVersions.get(targetKey) ?? 0; | ||
| 137 | } | ||
| 138 | |||
| 139 | function bumpLoadVersion(targetKey: string): number { | ||
| 140 | const next = getLoadVersion(targetKey) + 1; | ||
| 141 | loadVersions.set(targetKey, next); | ||
| 142 | return next; | ||
| 143 | } | ||
| 144 | |||
| 145 | function getSnapshot(target: VcsRefTarget): VcsRefState { | ||
| 146 | const targetKey = getVcsRefTargetKey(target); | ||
| 147 | if (targetKey === null) { | ||
| 148 | return EMPTY_VCS_REF_STATE; | ||
| 149 | } | ||
| 150 | return config.getRegistry().get(vcsRefStateAtom(targetKey)); | ||
| 151 | } | ||
| 152 | |||
| 153 | function setState(targetKey: string, nextState: VcsRefState): void { | ||
| 154 | config.getRegistry().set(vcsRefStateAtom(targetKey), nextState); | ||
| 155 | } | ||
| 156 | |||
| 157 | function markPending(targetKey: string): void { | ||
| 158 | const current = config.getRegistry().get(vcsRefStateAtom(targetKey)); | ||
| 159 | setState( | ||
| 160 | targetKey, | ||
| 161 | current.data === null ? INITIAL_VCS_REF_STATE : { ...current, isPending: true, error: null }, | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | |||
| 165 | function setData(targetKey: string, data: VcsListRefsResult): void { | ||
| 166 | lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis)); | ||
| 167 | setState(targetKey, { | ||
| 168 | data, | ||
| 169 | isPending: false, | ||
| 170 | error: null, | ||
| 171 | }); | ||
| 172 | } | ||
| 173 | |||
| 174 | function setError(targetKey: string, error: unknown): void { | ||
| 175 | const current = config.getRegistry().get(vcsRefStateAtom(targetKey)); | ||
| 176 | setState(targetKey, { | ||
| 177 | data: current.data, | ||
| 178 | isPending: false, | ||
| 179 | error: toErrorMessage(error), | ||
| 180 | }); | ||
| 181 | } | ||
| 182 | |||
| 183 | async function load( | ||
| 184 | target: VcsRefTarget, | ||
| 185 | client?: VcsRefClient, | ||
| 186 | options?: { | ||
| 187 | readonly cursor?: number; | ||
| 188 | readonly limit?: number; | ||
| 189 | readonly append?: boolean; | ||
| 190 | readonly preserveLoadedRefs?: boolean; | ||
| 191 | }, | ||
| 192 | ): Promise<VcsListRefsResult | null> { | ||
| 193 | const targetKey = getVcsRefTargetKey(target); | ||
| 194 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 195 | return null; | ||
| 196 | } | ||
| 197 | refreshTargets.set(targetKey, target); | ||
| 198 | |||
| 199 | const resolved = client ?? config.getClient(target.environmentId); | ||
| 200 | if (!resolved) { | ||
| 201 | return getSnapshot(target).data; | ||
| 202 | } | ||
| 203 | |||
| 204 | const inFlightKey = `${targetKey}:${options?.cursor ?? "start"}:${options?.append ? "append" : "replace"}`; | ||
| 205 | const existing = inFlight.get(inFlightKey); | ||
| 206 | if (existing && existing.client === resolved) { | ||
| 207 | return existing.promise; | ||
| 208 | } | ||
| 209 | |||
| 210 | markPending(targetKey); | ||
| 211 | const loadVersion = bumpLoadVersion(targetKey); | ||
| 212 | |||
| 213 | const current = getSnapshot(target).data; | ||
| 214 | const request: VcsListRefsInput = { | ||
| 215 | cwd: target.cwd, | ||
| 216 | ...(normalizeQuery(target.query).length > 0 ? { query: normalizeQuery(target.query) } : {}), | ||
| 217 | ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}), | ||
| 218 | ...(options?.limit !== undefined ? { limit: options.limit } : {}), | ||
| 219 | }; | ||
| 220 | |||
| 221 | const promise = resolved.listRefs(request).then( | ||
| 222 | (result) => { | ||
| 223 | const nextData = | ||
| 224 | options?.append && current | ||
| 225 | ? { | ||
| 226 | ...result, | ||
| 227 | refs: mergeRefs(current.refs, result.refs), | ||
| 228 | } | ||
| 229 | : options?.preserveLoadedRefs && current && current.refs.length > result.refs.length | ||
| 230 | ? { | ||
| 231 | ...result, | ||
| 232 | refs: mergeRefs(result.refs, current.refs), | ||
| 233 | nextCursor: current.nextCursor, | ||
| 234 | totalCount: Math.max(result.totalCount, current.totalCount), | ||
| 235 | } | ||
| 236 | : result; | ||
| 237 | if (getLoadVersion(targetKey) === loadVersion) { | ||
| 238 | setData(targetKey, nextData); | ||
| 239 | } | ||
| 240 | return nextData; | ||
| 241 | }, | ||
| 242 | (error) => { | ||
| 243 | if (getLoadVersion(targetKey) === loadVersion) { | ||
| 244 | setError(targetKey, error); | ||
| 245 | } | ||
| 246 | throw error; | ||
| 247 | }, | ||
| 248 | ); | ||
| 249 | |||
| 250 | inFlight.set(inFlightKey, { client: resolved, promise }); | ||
| 251 | try { | ||
| 252 | return await promise; | ||
| 253 | } finally { | ||
| 254 | if (inFlight.get(inFlightKey)?.promise === promise) { | ||
| 255 | inFlight.delete(inFlightKey); | ||
| 256 | } | ||
| 257 | } | ||
| 258 | } | ||
| 259 | |||
| 260 | function loadInBackground( | ||
| 261 | target: VcsRefTarget, | ||
| 262 | client: VcsRefClient, | ||
| 263 | options?: { | ||
| 264 | readonly cursor?: number; | ||
| 265 | readonly limit?: number; | ||
| 266 | readonly append?: boolean; | ||
| 267 | readonly preserveLoadedRefs?: boolean; | ||
| 268 | }, | ||
| 269 | ): void { | ||
| 270 | void load(target, client, options).catch((error: unknown) => { | ||
| 271 | config.onBackgroundError?.(error); | ||
| 272 | }); | ||
| 273 | } | ||
| 274 | |||
| 275 | async function loadNext( | ||
| 276 | target: VcsRefTarget, | ||
| 277 | client?: VcsRefClient, | ||
| 278 | options?: { readonly limit?: number }, | ||
| 279 | ): Promise<VcsListRefsResult | null> { | ||
| 280 | const current = getSnapshot(target).data; | ||
| 281 | if (!current?.nextCursor && current?.nextCursor !== 0) { | ||
| 282 | return current ?? null; | ||
| 283 | } | ||
| 284 | |||
| 285 | return load(target, client, { | ||
| 286 | cursor: current.nextCursor, | ||
| 287 | append: true, | ||
| 288 | ...(options?.limit !== undefined ? { limit: options.limit } : {}), | ||
| 289 | }); | ||
| 290 | } | ||
| 291 | |||
| 292 | function refreshWatchedTarget(targetKey: string, target: VcsRefTarget, client?: VcsRefClient) { | ||
| 293 | refreshTargets.set(targetKey, target); | ||
| 294 | |||
| 295 | if (client || config.staleTimeMs === undefined) { | ||
| 296 | const resolved = | ||
| 297 | client ?? (target.environmentId ? config.getClient(target.environmentId) : null); | ||
| 298 | if (resolved) { | ||
| 299 | loadInBackground(target, resolved, watchLoadOptions); | ||
| 300 | } | ||
| 301 | return; | ||
| 302 | } | ||
| 303 | |||
| 304 | const lastLoaded = lastLoadedAt.get(targetKey); | ||
| 305 | if ( | ||
| 306 | lastLoaded !== undefined && | ||
| 307 | Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs | ||
| 308 | ) { | ||
| 309 | return; | ||
| 310 | } | ||
| 311 | |||
| 312 | const result = config | ||
| 313 | .getRegistry() | ||
| 314 | .get(watchedRefreshAtom(targetKey)) as AsyncResult.AsyncResult< | ||
| 315 | VcsListRefsResult | null, | ||
| 316 | unknown | ||
| 317 | >; | ||
| 318 | if (result._tag === "Failure") { | ||
| 319 | config.onBackgroundError?.(result.cause); | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | function watch(target: VcsRefTarget, client?: VcsRefClient): () => void { | ||
| 324 | const targetKey = getVcsRefTargetKey(target); | ||
| 325 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 326 | return NOOP; | ||
| 327 | } | ||
| 328 | |||
| 329 | const existing = watched.get(targetKey); | ||
| 330 | if (existing) { | ||
| 331 | existing.refCount += 1; | ||
| 332 | return () => unwatch(targetKey); | ||
| 333 | } | ||
| 334 | |||
| 335 | let teardown: () => void; | ||
| 336 | |||
| 337 | if (client) { | ||
| 338 | refreshWatchedTarget(targetKey, target, client); | ||
| 339 | teardown = NOOP; | ||
| 340 | } else if (config.subscribeClientChanges) { | ||
| 341 | let currentClient: VcsRefClient | null = null; | ||
| 342 | const sync = () => { | ||
| 343 | const resolved = config.getClient(target.environmentId!); | ||
| 344 | if (!resolved) { | ||
| 345 | currentClient = null; | ||
| 346 | return; | ||
| 347 | } | ||
| 348 | if (currentClient === resolved) { | ||
| 349 | return; | ||
| 350 | } | ||
| 351 | |||
| 352 | const hadClient = currentClient !== null; | ||
| 353 | currentClient = resolved; | ||
| 354 | refreshWatchedTarget(targetKey, target, hadClient ? resolved : undefined); | ||
| 355 | }; | ||
| 356 | |||
| 357 | const unsubscribe = config.subscribeClientChanges(sync); | ||
| 358 | sync(); | ||
| 359 | teardown = unsubscribe; | ||
| 360 | } else { | ||
| 361 | const resolved = config.getClient(target.environmentId); | ||
| 362 | if (!resolved) { | ||
| 363 | return NOOP; | ||
| 364 | } | ||
| 365 | refreshWatchedTarget(targetKey, target); | ||
| 366 | teardown = NOOP; | ||
| 367 | } | ||
| 368 | |||
| 369 | watched.set(targetKey, { refCount: 1, teardown }); | ||
| 370 | return () => unwatch(targetKey); | ||
| 371 | } | ||
| 372 | |||
| 373 | function unwatch(targetKey: string): void { | ||
| 374 | const entry = watched.get(targetKey); | ||
| 375 | if (!entry) { | ||
| 376 | return; | ||
| 377 | } | ||
| 378 | |||
| 379 | entry.refCount -= 1; | ||
| 380 | if (entry.refCount > 0) { | ||
| 381 | return; | ||
| 382 | } | ||
| 383 | |||
| 384 | entry.teardown(); | ||
| 385 | watched.delete(targetKey); | ||
| 386 | } | ||
| 387 | |||
| 388 | function invalidate(target?: VcsRefTarget): void { | ||
| 389 | if (target) { | ||
| 390 | const targetKey = getVcsRefTargetKey(target); | ||
| 391 | if (targetKey !== null) { | ||
| 392 | bumpLoadVersion(targetKey); | ||
| 393 | setState(targetKey, EMPTY_VCS_REF_STATE); | ||
| 394 | for (const key of inFlight.keys()) { | ||
| 395 | if (key.startsWith(`${targetKey}:`)) { | ||
| 396 | inFlight.delete(key); | ||
| 397 | } | ||
| 398 | } | ||
| 399 | } | ||
| 400 | return; | ||
| 401 | } | ||
| 402 | |||
| 403 | for (const key of knownVcsRefKeys) { | ||
| 404 | bumpLoadVersion(key); | ||
| 405 | setState(key, EMPTY_VCS_REF_STATE); | ||
| 406 | } | ||
| 407 | inFlight.clear(); | ||
| 408 | } | ||
| 409 | |||
| 410 | function invalidateScope(scope: VcsRefScope): void { | ||
| 411 | if (scope.environmentId === null || scope.cwd === null) { | ||
| 412 | return; | ||
| 413 | } | ||
| 414 | |||
| 415 | const keyPrefix = `${scope.environmentId}:${scope.cwd}:`; | ||
| 416 | for (const key of knownVcsRefKeys) { | ||
| 417 | if (key.startsWith(keyPrefix)) { | ||
| 418 | bumpLoadVersion(key); | ||
| 419 | setState(key, EMPTY_VCS_REF_STATE); | ||
| 420 | } | ||
| 421 | } | ||
| 422 | |||
| 423 | for (const key of inFlight.keys()) { | ||
| 424 | if (key.startsWith(keyPrefix)) { | ||
| 425 | inFlight.delete(key); | ||
| 426 | } | ||
| 427 | } | ||
| 428 | } | ||
| 429 | |||
| 430 | function reset(): void { | ||
| 431 | for (const entry of watched.values()) { | ||
| 432 | entry.teardown(); | ||
| 433 | } | ||
| 434 | watched.clear(); | ||
| 435 | inFlight.clear(); | ||
| 436 | loadVersions.clear(); | ||
| 437 | lastLoadedAt.clear(); | ||
| 438 | refreshTargets.clear(); | ||
| 439 | invalidate(); | ||
| 440 | } | ||
| 441 | |||
| 442 | return { | ||
| 443 | getSnapshot, | ||
| 444 | watch, | ||
| 445 | load, | ||
| 446 | loadNext, | ||
| 447 | invalidate, | ||
| 448 | invalidateScope, | ||
| 449 | reset, | ||
| 450 | }; | ||
| 451 | } | ||
packages/client-runtime/src/vcsStatusState.test.ts created+330| ... | @@ -0,0 +1,330 @@ | ||
| 1 | import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts"; | ||
| 2 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 4 | |||
| 5 | import { type VcsStatusClient, createVcsStatusManager } from "./vcsStatusState.ts"; | ||
| 6 | |||
| 7 | /* ─── Test helpers ──────────────────────────────────────────────────── */ | ||
| 8 | |||
| 9 | let atomRegistry = AtomRegistry.make(); | ||
| 10 | |||
| 11 | function resetAtomRegistry() { | ||
| 12 | atomRegistry.dispose(); | ||
| 13 | atomRegistry = AtomRegistry.make(); | ||
| 14 | } | ||
| 15 | |||
| 16 | function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { | ||
| 17 | listeners.add(listener); | ||
| 18 | return () => { | ||
| 19 | listeners.delete(listener); | ||
| 20 | }; | ||
| 21 | } | ||
| 22 | |||
| 23 | const BASE_STATUS: VcsStatusResult = { | ||
| 24 | isRepo: true, | ||
| 25 | hasPrimaryRemote: true, | ||
| 26 | isDefaultRef: false, | ||
| 27 | refName: "feature/push-status", | ||
| 28 | hasWorkingTreeChanges: false, | ||
| 29 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 30 | hasUpstream: true, | ||
| 31 | aheadCount: 0, | ||
| 32 | behindCount: 0, | ||
| 33 | pr: null, | ||
| 34 | }; | ||
| 35 | |||
| 36 | function createMockClient(): { | ||
| 37 | client: VcsStatusClient; | ||
| 38 | listeners: Set<(event: VcsStatusResult) => void>; | ||
| 39 | emit: (event: VcsStatusResult) => void; | ||
| 40 | } { | ||
| 41 | const listeners = new Set<(event: VcsStatusResult) => void>(); | ||
| 42 | const client: VcsStatusClient = { | ||
| 43 | refreshStatus: vi.fn(async (input: { cwd: string }) => ({ | ||
| 44 | ...BASE_STATUS, | ||
| 45 | refName: `${input.cwd}-refreshed`, | ||
| 46 | })), | ||
| 47 | onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => | ||
| 48 | registerListener(listeners, listener), | ||
| 49 | ), | ||
| 50 | }; | ||
| 51 | return { | ||
| 52 | client, | ||
| 53 | listeners, | ||
| 54 | emit: (event) => { | ||
| 55 | for (const listener of listeners) listener(event); | ||
| 56 | }, | ||
| 57 | }; | ||
| 58 | } | ||
| 59 | |||
| 60 | const PENDING = { data: null, error: null, cause: null, isPending: true }; | ||
| 61 | const EMPTY = { data: null, error: null, cause: null, isPending: false }; | ||
| 62 | |||
| 63 | const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; | ||
| 64 | const FRESH_TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/fresh" } as const; | ||
| 65 | const OTHER_ENV_TARGET = { environmentId: EnvironmentId.make("env-remote"), cwd: "/repo" } as const; | ||
| 66 | |||
| 67 | /* ─── Tests ─────────────────────────────────────────────────────────── */ | ||
| 68 | |||
| 69 | describe("createVcsStatusManager", () => { | ||
| 70 | afterEach(() => { | ||
| 71 | resetAtomRegistry(); | ||
| 72 | }); | ||
| 73 | |||
| 74 | describe("with explicit client (no reconnection)", () => { | ||
| 75 | it("starts in a pending state when watching", () => { | ||
| 76 | const { client } = createMockClient(); | ||
| 77 | const manager = createVcsStatusManager({ | ||
| 78 | getRegistry: () => atomRegistry, | ||
| 79 | getClient: () => null, | ||
| 80 | }); | ||
| 81 | |||
| 82 | manager.watch(TARGET, client); | ||
| 83 | expect(manager.getSnapshot(TARGET)).toEqual(PENDING); | ||
| 84 | manager.reset(); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("shares one subscription per cwd and updates the snapshot", () => { | ||
| 88 | const { client, listeners, emit } = createMockClient(); | ||
| 89 | const manager = createVcsStatusManager({ | ||
| 90 | getRegistry: () => atomRegistry, | ||
| 91 | getClient: () => null, | ||
| 92 | }); | ||
| 93 | |||
| 94 | const releaseA = manager.watch(TARGET, client); | ||
| 95 | const releaseB = manager.watch(TARGET, client); | ||
| 96 | |||
| 97 | expect(client.onStatus).toHaveBeenCalledOnce(); | ||
| 98 | expect(manager.getSnapshot(TARGET)).toEqual(PENDING); | ||
| 99 | |||
| 100 | emit(BASE_STATUS); | ||
| 101 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 102 | data: BASE_STATUS, | ||
| 103 | error: null, | ||
| 104 | cause: null, | ||
| 105 | isPending: false, | ||
| 106 | }); | ||
| 107 | |||
| 108 | releaseA(); | ||
| 109 | expect(listeners.size).toBe(1); | ||
| 110 | |||
| 111 | releaseB(); | ||
| 112 | expect(listeners.size).toBe(0); | ||
| 113 | }); | ||
| 114 | |||
| 115 | it("refreshes via unary RPC without restarting the stream", async () => { | ||
| 116 | const { client, emit } = createMockClient(); | ||
| 117 | const manager = createVcsStatusManager({ | ||
| 118 | getRegistry: () => atomRegistry, | ||
| 119 | getClient: () => null, | ||
| 120 | }); | ||
| 121 | |||
| 122 | const release = manager.watch(TARGET, client); | ||
| 123 | emit(BASE_STATUS); | ||
| 124 | |||
| 125 | const refreshed = await manager.refresh(TARGET, client); | ||
| 126 | |||
| 127 | expect(client.onStatus).toHaveBeenCalledOnce(); | ||
| 128 | expect(client.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); | ||
| 129 | expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" }); | ||
| 130 | |||
| 131 | // Snapshot still reflects stream data, not the refresh response | ||
| 132 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 133 | data: BASE_STATUS, | ||
| 134 | error: null, | ||
| 135 | cause: null, | ||
| 136 | isPending: false, | ||
| 137 | }); | ||
| 138 | |||
| 139 | release(); | ||
| 140 | }); | ||
| 141 | |||
| 142 | it("keeps subscriptions isolated by environment when cwds match", () => { | ||
| 143 | const local = createMockClient(); | ||
| 144 | const remote = createMockClient(); | ||
| 145 | const manager = createVcsStatusManager({ | ||
| 146 | getRegistry: () => atomRegistry, | ||
| 147 | getClient: () => null, | ||
| 148 | }); | ||
| 149 | |||
| 150 | const releaseLocal = manager.watch(TARGET, local.client); | ||
| 151 | const releaseRemote = manager.watch(OTHER_ENV_TARGET, remote.client); | ||
| 152 | |||
| 153 | local.emit(BASE_STATUS); | ||
| 154 | remote.emit({ ...BASE_STATUS, refName: "remote-branch" }); | ||
| 155 | |||
| 156 | expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); | ||
| 157 | expect(manager.getSnapshot(OTHER_ENV_TARGET).data?.refName).toBe("remote-branch"); | ||
| 158 | |||
| 159 | releaseLocal(); | ||
| 160 | releaseRemote(); | ||
| 161 | }); | ||
| 162 | |||
| 163 | it("returns null from refresh when no client is available", async () => { | ||
| 164 | const manager = createVcsStatusManager({ | ||
| 165 | getRegistry: () => atomRegistry, | ||
| 166 | getClient: () => null, | ||
| 167 | }); | ||
| 168 | |||
| 169 | await expect(manager.refresh(TARGET)).resolves.toBeNull(); | ||
| 170 | }); | ||
| 171 | |||
| 172 | it("returns empty state for null targets", () => { | ||
| 173 | const manager = createVcsStatusManager({ | ||
| 174 | getRegistry: () => atomRegistry, | ||
| 175 | getClient: () => null, | ||
| 176 | }); | ||
| 177 | |||
| 178 | expect(manager.getSnapshot({ environmentId: null, cwd: null })).toEqual(EMPTY); | ||
| 179 | }); | ||
| 180 | }); | ||
| 181 | |||
| 182 | describe("with subscribeClientChanges (reconnection)", () => { | ||
| 183 | it("waits for a delayed client registration", () => { | ||
| 184 | const connectionListeners = new Set<() => void>(); | ||
| 185 | const clients = new Map<string, ReturnType<typeof createMockClient>>(); | ||
| 186 | |||
| 187 | const manager = createVcsStatusManager({ | ||
| 188 | getRegistry: () => atomRegistry, | ||
| 189 | getClient: (envId) => clients.get(envId)?.client ?? null, | ||
| 190 | getClientIdentity: (envId) => (clients.has(envId) ? envId : null), | ||
| 191 | subscribeClientChanges: (listener) => { | ||
| 192 | connectionListeners.add(listener); | ||
| 193 | return () => connectionListeners.delete(listener); | ||
| 194 | }, | ||
| 195 | }); | ||
| 196 | |||
| 197 | const release = manager.watch(TARGET); | ||
| 198 | expect(manager.getSnapshot(TARGET)).toEqual(PENDING); | ||
| 199 | |||
| 200 | // Register the client | ||
| 201 | const mock = createMockClient(); | ||
| 202 | clients.set("env-local", mock); | ||
| 203 | for (const listener of connectionListeners) listener(); | ||
| 204 | |||
| 205 | mock.emit(BASE_STATUS); | ||
| 206 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 207 | data: BASE_STATUS, | ||
| 208 | error: null, | ||
| 209 | cause: null, | ||
| 210 | isPending: false, | ||
| 211 | }); | ||
| 212 | |||
| 213 | release(); | ||
| 214 | }); | ||
| 215 | |||
| 216 | it("resubscribes after client is removed and re-registered", () => { | ||
| 217 | const connectionListeners = new Set<() => void>(); | ||
| 218 | const clients = new Map<string, ReturnType<typeof createMockClient>>(); | ||
| 219 | |||
| 220 | const manager = createVcsStatusManager({ | ||
| 221 | getRegistry: () => atomRegistry, | ||
| 222 | getClient: (envId) => clients.get(envId)?.client ?? null, | ||
| 223 | getClientIdentity: (envId) => | ||
| 224 | clients.get(envId) ? `identity:${envId}:${clients.size}` : null, | ||
| 225 | subscribeClientChanges: (listener) => { | ||
| 226 | connectionListeners.add(listener); | ||
| 227 | return () => connectionListeners.delete(listener); | ||
| 228 | }, | ||
| 229 | }); | ||
| 230 | |||
| 231 | // Register first client and watch | ||
| 232 | const first = createMockClient(); | ||
| 233 | clients.set("env-local", first); | ||
| 234 | const release = manager.watch(TARGET); | ||
| 235 | |||
| 236 | first.emit(BASE_STATUS); | ||
| 237 | expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); | ||
| 238 | |||
| 239 | // Remove client | ||
| 240 | clients.delete("env-local"); | ||
| 241 | for (const listener of connectionListeners) listener(); | ||
| 242 | |||
| 243 | expect(manager.getSnapshot(TARGET)).toEqual({ | ||
| 244 | data: BASE_STATUS, | ||
| 245 | error: null, | ||
| 246 | cause: null, | ||
| 247 | isPending: true, | ||
| 248 | }); | ||
| 249 | |||
| 250 | // Register new client (different identity) | ||
| 251 | const second = createMockClient(); | ||
| 252 | clients.set("env-local", second); | ||
| 253 | for (const listener of connectionListeners) listener(); | ||
| 254 | |||
| 255 | second.emit({ ...BASE_STATUS, refName: "reconnected-branch" }); | ||
| 256 | expect(manager.getSnapshot(TARGET).data?.refName).toBe("reconnected-branch"); | ||
| 257 | |||
| 258 | release(); | ||
| 259 | }); | ||
| 260 | |||
| 261 | it("cleans up connection listener on unwatch", () => { | ||
| 262 | const connectionListeners = new Set<() => void>(); | ||
| 263 | const mock = createMockClient(); | ||
| 264 | |||
| 265 | const manager = createVcsStatusManager({ | ||
| 266 | getRegistry: () => atomRegistry, | ||
| 267 | getClient: () => mock.client, | ||
| 268 | getClientIdentity: () => "id", | ||
| 269 | subscribeClientChanges: (listener) => { | ||
| 270 | connectionListeners.add(listener); | ||
| 271 | return () => connectionListeners.delete(listener); | ||
| 272 | }, | ||
| 273 | }); | ||
| 274 | |||
| 275 | const release = manager.watch(TARGET); | ||
| 276 | expect(connectionListeners.size).toBe(1); | ||
| 277 | |||
| 278 | release(); | ||
| 279 | expect(connectionListeners.size).toBe(0); | ||
| 280 | expect(mock.listeners.size).toBe(0); | ||
| 281 | }); | ||
| 282 | }); | ||
| 283 | |||
| 284 | describe("with getClient config (one-shot)", () => { | ||
| 285 | it("resolves client from config and subscribes", () => { | ||
| 286 | const mock = createMockClient(); | ||
| 287 | const manager = createVcsStatusManager({ | ||
| 288 | getRegistry: () => atomRegistry, | ||
| 289 | getClient: (envId) => (envId === "env-local" ? mock.client : null), | ||
| 290 | }); | ||
| 291 | |||
| 292 | const release = manager.watch(TARGET); | ||
| 293 | expect(mock.client.onStatus).toHaveBeenCalledOnce(); | ||
| 294 | |||
| 295 | mock.emit(BASE_STATUS); | ||
| 296 | expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); | ||
| 297 | |||
| 298 | release(); | ||
| 299 | expect(mock.listeners.size).toBe(0); | ||
| 300 | }); | ||
| 301 | |||
| 302 | it("returns noop when client is not available", () => { | ||
| 303 | const manager = createVcsStatusManager({ | ||
| 304 | getRegistry: () => atomRegistry, | ||
| 305 | getClient: () => null, | ||
| 306 | }); | ||
| 307 | |||
| 308 | const release = manager.watch(TARGET); | ||
| 309 | expect(manager.getSnapshot(TARGET)).toEqual(PENDING); | ||
| 310 | release(); // should not throw | ||
| 311 | }); | ||
| 312 | }); | ||
| 313 | |||
| 314 | describe("reset", () => { | ||
| 315 | it("tears down all active subscriptions", () => { | ||
| 316 | const mock = createMockClient(); | ||
| 317 | const manager = createVcsStatusManager({ | ||
| 318 | getRegistry: () => atomRegistry, | ||
| 319 | getClient: () => mock.client, | ||
| 320 | }); | ||
| 321 | |||
| 322 | manager.watch(TARGET); | ||
| 323 | manager.watch(FRESH_TARGET); | ||
| 324 | expect(mock.listeners.size).toBe(2); | ||
| 325 | |||
| 326 | manager.reset(); | ||
| 327 | expect(mock.listeners.size).toBe(0); | ||
| 328 | }); | ||
| 329 | }); | ||
| 330 | }); | ||
packages/client-runtime/src/vcsStatusState.ts created+292| ... | @@ -0,0 +1,292 @@ | ||
| 1 | import type { EnvironmentId, GitManagerServiceError, VcsStatusResult } from "@t3tools/contracts"; | ||
| 2 | import type * as Cause from "effect/Cause"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 5 | import type { WsRpcClient } from "./wsRpcClient.ts"; | ||
| 6 | |||
| 7 | /* ─── Types ─────────────────────────────────────────────────────────── */ | ||
| 8 | |||
| 9 | export interface VcsStatusState { | ||
| 10 | readonly data: VcsStatusResult | null; | ||
| 11 | readonly error: GitManagerServiceError | null; | ||
| 12 | readonly cause: Cause.Cause<GitManagerServiceError> | null; | ||
| 13 | readonly isPending: boolean; | ||
| 14 | } | ||
| 15 | |||
| 16 | export interface VcsStatusTarget { | ||
| 17 | readonly environmentId: EnvironmentId | null; | ||
| 18 | readonly cwd: string | null; | ||
| 19 | } | ||
| 20 | |||
| 21 | export type VcsStatusClient = Pick<WsRpcClient["vcs"], "onStatus" | "refreshStatus">; | ||
| 22 | |||
| 23 | interface WatchedEntry { | ||
| 24 | refCount: number; | ||
| 25 | teardown: () => void; | ||
| 26 | } | ||
| 27 | |||
| 28 | /* ─── Constants ─────────────────────────────────────────────────────── */ | ||
| 29 | |||
| 30 | const NOOP: () => void = () => undefined; | ||
| 31 | |||
| 32 | export const EMPTY_VCS_STATUS_STATE = Object.freeze<VcsStatusState>({ | ||
| 33 | data: null, | ||
| 34 | error: null, | ||
| 35 | cause: null, | ||
| 36 | isPending: false, | ||
| 37 | }); | ||
| 38 | |||
| 39 | const INITIAL_VCS_STATUS_STATE = Object.freeze<VcsStatusState>({ | ||
| 40 | data: null, | ||
| 41 | error: null, | ||
| 42 | cause: null, | ||
| 43 | isPending: true, | ||
| 44 | }); | ||
| 45 | |||
| 46 | /* ─── Atoms ─────────────────────────────────────────────────────────── */ | ||
| 47 | |||
| 48 | const knownVcsStatusKeys = new Set<string>(); | ||
| 49 | |||
| 50 | export const vcsStatusStateAtom = Atom.family((key: string) => { | ||
| 51 | knownVcsStatusKeys.add(key); | ||
| 52 | return Atom.make(INITIAL_VCS_STATUS_STATE).pipe( | ||
| 53 | Atom.keepAlive, | ||
| 54 | Atom.withLabel(`vcs-status:${key}`), | ||
| 55 | ); | ||
| 56 | }); | ||
| 57 | |||
| 58 | export const EMPTY_VCS_STATUS_ATOM = Atom.make(EMPTY_VCS_STATUS_STATE).pipe( | ||
| 59 | Atom.keepAlive, | ||
| 60 | Atom.withLabel("vcs-status:null"), | ||
| 61 | ); | ||
| 62 | |||
| 63 | /* ─── Helpers ───────────────────────────────────────────────────────── */ | ||
| 64 | |||
| 65 | export function getVcsStatusTargetKey(target: VcsStatusTarget): string | null { | ||
| 66 | if (target.environmentId === null || target.cwd === null) { | ||
| 67 | return null; | ||
| 68 | } | ||
| 69 | return `${target.environmentId}:${target.cwd}`; | ||
| 70 | } | ||
| 71 | |||
| 72 | /* ─── Subscription manager ──────────────────────────────────────────── */ | ||
| 73 | |||
| 74 | export interface VcsStatusManagerConfig { | ||
| 75 | /** | ||
| 76 | * Get the atom registry to read/write VCS status atoms. | ||
| 77 | */ | ||
| 78 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 79 | /** Resolve a VCS client for an environment. */ | ||
| 80 | readonly getClient: (environmentId: EnvironmentId) => VcsStatusClient | null; | ||
| 81 | /** | ||
| 82 | * Optional: get a stable identity for the current client. | ||
| 83 | * Used to detect reconnections — when the identity changes the | ||
| 84 | * manager tears down the old `onStatus` stream and subscribes anew. | ||
| 85 | */ | ||
| 86 | readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null; | ||
| 87 | /** | ||
| 88 | * Optional: subscribe to environment-connection changes. | ||
| 89 | * When provided the manager reacts to client appear / disappear / | ||
| 90 | * reconnect events instead of doing a one-shot resolution. | ||
| 91 | */ | ||
| 92 | readonly subscribeClientChanges?: (listener: () => void) => () => void; | ||
| 93 | } | ||
| 94 | |||
| 95 | const VCS_STATUS_REFRESH_DEBOUNCE_MS = 1_000; | ||
| 96 | const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); | ||
| 97 | |||
| 98 | export function createVcsStatusManager(config: VcsStatusManagerConfig) { | ||
| 99 | const watched = new Map<string, WatchedEntry>(); | ||
| 100 | const refreshInFlight = new Map<string, Promise<VcsStatusResult>>(); | ||
| 101 | const lastRefreshAt = new Map<string, number>(); | ||
| 102 | |||
| 103 | /* ── Atom helpers ───────────────────────────────────────────────── */ | ||
| 104 | |||
| 105 | function markPending(targetKey: string): void { | ||
| 106 | const atom = vcsStatusStateAtom(targetKey); | ||
| 107 | const current = config.getRegistry().get(atom); | ||
| 108 | const next: VcsStatusState = | ||
| 109 | current.data === null | ||
| 110 | ? INITIAL_VCS_STATUS_STATE | ||
| 111 | : { ...current, error: null, cause: null, isPending: true }; | ||
| 112 | if ( | ||
| 113 | current.data === next.data && | ||
| 114 | current.error === next.error && | ||
| 115 | current.cause === next.cause && | ||
| 116 | current.isPending === next.isPending | ||
| 117 | ) { | ||
| 118 | return; | ||
| 119 | } | ||
| 120 | config.getRegistry().set(atom, next); | ||
| 121 | } | ||
| 122 | |||
| 123 | function setData(targetKey: string, status: VcsStatusResult): void { | ||
| 124 | config.getRegistry().set(vcsStatusStateAtom(targetKey), { | ||
| 125 | data: status, | ||
| 126 | error: null, | ||
| 127 | cause: null, | ||
| 128 | isPending: false, | ||
| 129 | }); | ||
| 130 | } | ||
| 131 | |||
| 132 | /* ── Core subscription ──────────────────────────────────────────── */ | ||
| 133 | |||
| 134 | function subscribeStream(targetKey: string, cwd: string, client: VcsStatusClient): () => void { | ||
| 135 | markPending(targetKey); | ||
| 136 | return client.onStatus({ cwd }, (status) => setData(targetKey, status), { | ||
| 137 | onResubscribe: () => markPending(targetKey), | ||
| 138 | }); | ||
| 139 | } | ||
| 140 | |||
| 141 | /* ── Dynamic subscription (handles reconnection) ────────────────── */ | ||
| 142 | |||
| 143 | function createDynamicSubscription(targetKey: string, target: VcsStatusTarget): () => void { | ||
| 144 | const environmentId = target.environmentId!; | ||
| 145 | const cwd = target.cwd!; | ||
| 146 | let currentIdentity: string | null = null; | ||
| 147 | let currentUnsub = NOOP; | ||
| 148 | |||
| 149 | const sync = () => { | ||
| 150 | const client = config.getClient(environmentId); | ||
| 151 | const identity = client ? (config.getClientIdentity?.(environmentId) ?? environmentId) : null; | ||
| 152 | |||
| 153 | if (!client || identity === null) { | ||
| 154 | if (currentIdentity !== null) { | ||
| 155 | currentUnsub(); | ||
| 156 | currentUnsub = NOOP; | ||
| 157 | currentIdentity = null; | ||
| 158 | } | ||
| 159 | markPending(targetKey); | ||
| 160 | return; | ||
| 161 | } | ||
| 162 | |||
| 163 | if (currentIdentity === identity) return; | ||
| 164 | |||
| 165 | currentUnsub(); | ||
| 166 | currentIdentity = identity; | ||
| 167 | currentUnsub = subscribeStream(targetKey, cwd, client); | ||
| 168 | }; | ||
| 169 | |||
| 170 | const unsubChanges = config.subscribeClientChanges!(sync); | ||
| 171 | sync(); | ||
| 172 | |||
| 173 | return () => { | ||
| 174 | unsubChanges(); | ||
| 175 | currentUnsub(); | ||
| 176 | }; | ||
| 177 | } | ||
| 178 | |||
| 179 | /* ── Public API ─────────────────────────────────────────────────── */ | ||
| 180 | |||
| 181 | /** | ||
| 182 | * Begin watching VCS status for `target`. | ||
| 183 | * | ||
| 184 | * Multiple watchers sharing the same `environmentId:cwd` key share | ||
| 185 | * one `onStatus` WS subscription (ref-counted). | ||
| 186 | * | ||
| 187 | * @param target The environment + cwd to watch. | ||
| 188 | * @param client Optional pre-resolved client — skips `getClient` | ||
| 189 | * lookup and reconnection handling. Useful in tests. | ||
| 190 | * @returns An unwatch function. | ||
| 191 | */ | ||
| 192 | function watch(target: VcsStatusTarget, client?: VcsStatusClient): () => void { | ||
| 193 | const targetKey = getVcsStatusTargetKey(target); | ||
| 194 | if (targetKey === null || target.environmentId === null || target.cwd === null) { | ||
| 195 | return NOOP; | ||
| 196 | } | ||
| 197 | |||
| 198 | const existing = watched.get(targetKey); | ||
| 199 | if (existing) { | ||
| 200 | existing.refCount += 1; | ||
| 201 | return () => unwatch(targetKey); | ||
| 202 | } | ||
| 203 | |||
| 204 | let teardown: () => void; | ||
| 205 | |||
| 206 | if (client) { | ||
| 207 | // Explicit client — direct subscription, no reconnection handling. | ||
| 208 | teardown = subscribeStream(targetKey, target.cwd, client); | ||
| 209 | } else if (config.subscribeClientChanges) { | ||
| 210 | // Dynamic client — subscribe to connection changes for reconnection. | ||
| 211 | teardown = createDynamicSubscription(targetKey, target); | ||
| 212 | } else { | ||
| 213 | // One-shot client resolution. | ||
| 214 | const resolved = config.getClient(target.environmentId); | ||
| 215 | if (!resolved) return NOOP; | ||
| 216 | teardown = subscribeStream(targetKey, target.cwd, resolved); | ||
| 217 | } | ||
| 218 | |||
| 219 | watched.set(targetKey, { refCount: 1, teardown }); | ||
| 220 | return () => unwatch(targetKey); | ||
| 221 | } | ||
| 222 | |||
| 223 | function unwatch(targetKey: string): void { | ||
| 224 | const entry = watched.get(targetKey); | ||
| 225 | if (!entry) return; | ||
| 226 | |||
| 227 | entry.refCount -= 1; | ||
| 228 | if (entry.refCount > 0) return; | ||
| 229 | |||
| 230 | entry.teardown(); | ||
| 231 | watched.delete(targetKey); | ||
| 232 | } | ||
| 233 | |||
| 234 | /** | ||
| 235 | * Trigger a one-shot `refreshStatus` RPC for a target. | ||
| 236 | * Debounced (1 s) and deduplicated (in-flight). | ||
| 237 | * The server-side refresh pushes a new event on the existing | ||
| 238 | * `onStatus` stream, so the subscription picks it up automatically. | ||
| 239 | */ | ||
| 240 | function refresh( | ||
| 241 | target: VcsStatusTarget, | ||
| 242 | client?: VcsStatusClient, | ||
| 243 | ): Promise<VcsStatusResult | null> { | ||
| 244 | const targetKey = getVcsStatusTargetKey(target); | ||
| 245 | if (targetKey === null || target.cwd === null) { | ||
| 246 | return Promise.resolve(null); | ||
| 247 | } | ||
| 248 | |||
| 249 | const resolved = | ||
| 250 | client ?? (target.environmentId ? config.getClient(target.environmentId) : null); | ||
| 251 | if (!resolved) { | ||
| 252 | return Promise.resolve(getSnapshot(target).data); | ||
| 253 | } | ||
| 254 | |||
| 255 | const existing = refreshInFlight.get(targetKey); | ||
| 256 | if (existing) return existing; | ||
| 257 | |||
| 258 | const requestedAt = nowMs(); | ||
| 259 | const last = lastRefreshAt.get(targetKey) ?? 0; | ||
| 260 | if (requestedAt - last < VCS_STATUS_REFRESH_DEBOUNCE_MS) { | ||
| 261 | return Promise.resolve(getSnapshot(target).data); | ||
| 262 | } | ||
| 263 | |||
| 264 | lastRefreshAt.set(targetKey, requestedAt); | ||
| 265 | const promise = resolved | ||
| 266 | .refreshStatus({ cwd: target.cwd }) | ||
| 267 | .finally(() => refreshInFlight.delete(targetKey)); | ||
| 268 | refreshInFlight.set(targetKey, promise); | ||
| 269 | return promise; | ||
| 270 | } | ||
| 271 | |||
| 272 | function getSnapshot(target: VcsStatusTarget): VcsStatusState { | ||
| 273 | const targetKey = getVcsStatusTargetKey(target); | ||
| 274 | if (targetKey === null) return EMPTY_VCS_STATUS_STATE; | ||
| 275 | return config.getRegistry().get(vcsStatusStateAtom(targetKey)); | ||
| 276 | } | ||
| 277 | |||
| 278 | function reset(): void { | ||
| 279 | for (const entry of watched.values()) { | ||
| 280 | entry.teardown(); | ||
| 281 | } | ||
| 282 | watched.clear(); | ||
| 283 | refreshInFlight.clear(); | ||
| 284 | lastRefreshAt.clear(); | ||
| 285 | for (const key of knownVcsStatusKeys) { | ||
| 286 | config.getRegistry().set(vcsStatusStateAtom(key), INITIAL_VCS_STATUS_STATE); | ||
| 287 | } | ||
| 288 | knownVcsStatusKeys.clear(); | ||
| 289 | } | ||
| 290 | |||
| 291 | return { watch, refresh, getSnapshot, reset }; | ||
| 292 | } | ||
packages/client-runtime/src/wsRpcClient.test.ts created+186| ... | @@ -0,0 +1,186 @@ | ||
| 1 | import type { | ||
| 2 | VcsStatusLocalResult, | ||
| 3 | VcsStatusRemoteResult, | ||
| 4 | VcsStatusStreamEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { ORCHESTRATION_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; | ||
| 7 | import { describe, expect, it, vi } from "vitest"; | ||
| 8 | |||
| 9 | vi.mock("./wsTransport.ts", () => ({ | ||
| 10 | WsTransport: class WsTransport { | ||
| 11 | dispose = vi.fn(async () => undefined); | ||
| 12 | reconnect = vi.fn(async () => undefined); | ||
| 13 | request = vi.fn(); | ||
| 14 | requestStream = vi.fn(); | ||
| 15 | subscribe = vi.fn(() => () => undefined); | ||
| 16 | }, | ||
| 17 | })); | ||
| 18 | |||
| 19 | import { createWsRpcClient } from "./wsRpcClient.ts"; | ||
| 20 | import type { WsTransport } from "./wsTransport.ts"; | ||
| 21 | |||
| 22 | const baseLocalStatus: VcsStatusLocalResult = { | ||
| 23 | isRepo: true, | ||
| 24 | hasPrimaryRemote: true, | ||
| 25 | isDefaultRef: false, | ||
| 26 | refName: "feature/demo", | ||
| 27 | hasWorkingTreeChanges: false, | ||
| 28 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 29 | }; | ||
| 30 | |||
| 31 | const baseRemoteStatus: VcsStatusRemoteResult = { | ||
| 32 | hasUpstream: true, | ||
| 33 | aheadCount: 0, | ||
| 34 | behindCount: 0, | ||
| 35 | pr: null, | ||
| 36 | }; | ||
| 37 | |||
| 38 | describe("createWsRpcClient", () => { | ||
| 39 | it("runs beforeReconnect before awaiting transport.reconnect", async () => { | ||
| 40 | const order: string[] = []; | ||
| 41 | const transport = { | ||
| 42 | dispose: vi.fn(async () => undefined), | ||
| 43 | reconnect: vi.fn(async () => { | ||
| 44 | order.push("reconnect"); | ||
| 45 | }), | ||
| 46 | isHeartbeatFresh: vi.fn(() => true), | ||
| 47 | request: vi.fn(), | ||
| 48 | requestStream: vi.fn(), | ||
| 49 | subscribe: vi.fn(() => () => undefined), | ||
| 50 | } satisfies Pick< | ||
| 51 | WsTransport, | ||
| 52 | "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" | ||
| 53 | >; | ||
| 54 | |||
| 55 | const client = createWsRpcClient(transport as unknown as WsTransport, { | ||
| 56 | beforeReconnect: () => { | ||
| 57 | order.push("beforeReconnect"); | ||
| 58 | }, | ||
| 59 | }); | ||
| 60 | |||
| 61 | await client.reconnect(); | ||
| 62 | expect(order).toEqual(["beforeReconnect", "reconnect"]); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("delegates heartbeat freshness to the transport", () => { | ||
| 66 | const isHeartbeatFresh = vi.fn(() => true); | ||
| 67 | const transport = { | ||
| 68 | dispose: vi.fn(async () => undefined), | ||
| 69 | reconnect: vi.fn(async () => undefined), | ||
| 70 | isHeartbeatFresh, | ||
| 71 | request: vi.fn(), | ||
| 72 | requestStream: vi.fn(), | ||
| 73 | subscribe: vi.fn(() => () => undefined), | ||
| 74 | } satisfies Pick< | ||
| 75 | WsTransport, | ||
| 76 | "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" | ||
| 77 | >; | ||
| 78 | |||
| 79 | const client = createWsRpcClient(transport as unknown as WsTransport); | ||
| 80 | |||
| 81 | expect(client.isHeartbeatFresh()).toBe(true); | ||
| 82 | expect(isHeartbeatFresh).toHaveBeenCalledOnce(); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("reduces vcs status stream events into flat status snapshots", () => { | ||
| 86 | const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => { | ||
| 87 | for (const event of [ | ||
| 88 | { | ||
| 89 | _tag: "snapshot", | ||
| 90 | local: baseLocalStatus, | ||
| 91 | remote: null, | ||
| 92 | }, | ||
| 93 | { | ||
| 94 | _tag: "remoteUpdated", | ||
| 95 | remote: baseRemoteStatus, | ||
| 96 | }, | ||
| 97 | { | ||
| 98 | _tag: "localUpdated", | ||
| 99 | local: { | ||
| 100 | ...baseLocalStatus, | ||
| 101 | hasWorkingTreeChanges: true, | ||
| 102 | }, | ||
| 103 | }, | ||
| 104 | ] satisfies VcsStatusStreamEvent[]) { | ||
| 105 | listener(event as TValue); | ||
| 106 | } | ||
| 107 | return () => undefined; | ||
| 108 | }); | ||
| 109 | |||
| 110 | const transport = { | ||
| 111 | dispose: vi.fn(async () => undefined), | ||
| 112 | reconnect: vi.fn(async () => undefined), | ||
| 113 | isHeartbeatFresh: vi.fn(() => true), | ||
| 114 | request: vi.fn(), | ||
| 115 | requestStream: vi.fn(), | ||
| 116 | subscribe, | ||
| 117 | } satisfies Pick< | ||
| 118 | WsTransport, | ||
| 119 | "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" | ||
| 120 | >; | ||
| 121 | |||
| 122 | const client = createWsRpcClient(transport as unknown as WsTransport); | ||
| 123 | const listener = vi.fn(); | ||
| 124 | |||
| 125 | client.vcs.onStatus({ cwd: "/repo" }, listener); | ||
| 126 | |||
| 127 | expect(listener.mock.calls).toEqual([ | ||
| 128 | [ | ||
| 129 | { | ||
| 130 | ...baseLocalStatus, | ||
| 131 | hasUpstream: false, | ||
| 132 | aheadCount: 0, | ||
| 133 | behindCount: 0, | ||
| 134 | aheadOfDefaultCount: 0, | ||
| 135 | pr: null, | ||
| 136 | }, | ||
| 137 | ], | ||
| 138 | [ | ||
| 139 | { | ||
| 140 | ...baseLocalStatus, | ||
| 141 | ...baseRemoteStatus, | ||
| 142 | }, | ||
| 143 | ], | ||
| 144 | [ | ||
| 145 | { | ||
| 146 | ...baseLocalStatus, | ||
| 147 | ...baseRemoteStatus, | ||
| 148 | hasWorkingTreeChanges: true, | ||
| 149 | }, | ||
| 150 | ], | ||
| 151 | ]); | ||
| 152 | }); | ||
| 153 | |||
| 154 | it("tags stream subscriptions for targeted resubscribe handling", () => { | ||
| 155 | const subscribe = vi.fn(() => () => undefined); | ||
| 156 | const transport = { | ||
| 157 | dispose: vi.fn(async () => undefined), | ||
| 158 | reconnect: vi.fn(async () => undefined), | ||
| 159 | isHeartbeatFresh: vi.fn(() => true), | ||
| 160 | request: vi.fn(), | ||
| 161 | requestStream: vi.fn(), | ||
| 162 | subscribe, | ||
| 163 | } satisfies Pick< | ||
| 164 | WsTransport, | ||
| 165 | "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" | ||
| 166 | >; | ||
| 167 | |||
| 168 | const client = createWsRpcClient(transport as unknown as WsTransport); | ||
| 169 | const listener = vi.fn(); | ||
| 170 | |||
| 171 | client.terminal.onMetadata(listener); | ||
| 172 | client.vcs.onStatus({ cwd: "/repo" }, listener); | ||
| 173 | client.server.subscribeConfig(listener); | ||
| 174 | client.orchestration.subscribeThread({ threadId: ThreadId.make("thread-1") }, listener); | ||
| 175 | |||
| 176 | const subscribeCalls = subscribe.mock.calls as unknown as Array< | ||
| 177 | readonly [unknown, unknown, { readonly tag?: string }?] | ||
| 178 | >; | ||
| 179 | expect(subscribeCalls.map((call) => call[2]?.tag)).toEqual([ | ||
| 180 | WS_METHODS.subscribeTerminalMetadata, | ||
| 181 | WS_METHODS.subscribeVcsStatus, | ||
| 182 | WS_METHODS.subscribeServerConfig, | ||
| 183 | ORCHESTRATION_WS_METHODS.subscribeThread, | ||
| 184 | ]); | ||
| 185 | }); | ||
| 186 | }); | ||
packages/client-runtime/src/wsRpcClient.ts created+348| ... | @@ -0,0 +1,348 @@ | ||
| 1 | import { | ||
| 2 | type GitActionProgressEvent, | ||
| 3 | type GitRunStackedActionInput, | ||
| 4 | type GitRunStackedActionResult, | ||
| 5 | type LocalApi, | ||
| 6 | ORCHESTRATION_WS_METHODS, | ||
| 7 | type ServerSettingsPatch, | ||
| 8 | type VcsStatusResult, | ||
| 9 | type VcsStatusStreamEvent, | ||
| 10 | WS_METHODS, | ||
| 11 | } from "@t3tools/contracts"; | ||
| 12 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; | ||
| 13 | import type * as Effect from "effect/Effect"; | ||
| 14 | import type * as Stream from "effect/Stream"; | ||
| 15 | |||
| 16 | import { type WsRpcProtocolClient } from "./wsRpcProtocol.ts"; | ||
| 17 | import { WsTransport } from "./wsTransport.ts"; | ||
| 18 | |||
| 19 | type RpcTag = keyof WsRpcProtocolClient & string; | ||
| 20 | type RpcMethod<TTag extends RpcTag> = WsRpcProtocolClient[TTag]; | ||
| 21 | type RpcInput<TTag extends RpcTag> = Parameters<RpcMethod<TTag>>[0]; | ||
| 22 | |||
| 23 | interface StreamSubscriptionOptions { | ||
| 24 | readonly onResubscribe?: () => void; | ||
| 25 | } | ||
| 26 | |||
| 27 | function subscriptionOptions( | ||
| 28 | options: StreamSubscriptionOptions | undefined, | ||
| 29 | tag: string, | ||
| 30 | ): StreamSubscriptionOptions & { readonly tag: string } { | ||
| 31 | return { | ||
| 32 | ...options, | ||
| 33 | tag, | ||
| 34 | }; | ||
| 35 | } | ||
| 36 | |||
| 37 | type RpcUnaryMethod<TTag extends RpcTag> = | ||
| 38 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | ||
| 39 | ? (input: RpcInput<TTag>) => Promise<TSuccess> | ||
| 40 | : never; | ||
| 41 | |||
| 42 | type RpcUnaryNoArgMethod<TTag extends RpcTag> = | ||
| 43 | RpcMethod<TTag> extends (input: any, options?: any) => Effect.Effect<infer TSuccess, any, any> | ||
| 44 | ? () => Promise<TSuccess> | ||
| 45 | : never; | ||
| 46 | |||
| 47 | type RpcStreamMethod<TTag extends RpcTag> = | ||
| 48 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any> | ||
| 49 | ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void | ||
| 50 | : never; | ||
| 51 | |||
| 52 | type RpcInputStreamMethod<TTag extends RpcTag> = | ||
| 53 | RpcMethod<TTag> extends (input: any, options?: any) => Stream.Stream<infer TEvent, any, any> | ||
| 54 | ? ( | ||
| 55 | input: RpcInput<TTag>, | ||
| 56 | listener: (event: TEvent) => void, | ||
| 57 | options?: StreamSubscriptionOptions, | ||
| 58 | ) => () => void | ||
| 59 | : never; | ||
| 60 | |||
| 61 | interface GitRunStackedActionOptions { | ||
| 62 | readonly onProgress?: (event: GitActionProgressEvent) => void; | ||
| 63 | } | ||
| 64 | |||
| 65 | export interface WsRpcClient { | ||
| 66 | readonly dispose: () => Promise<void>; | ||
| 67 | readonly reconnect: () => Promise<void>; | ||
| 68 | readonly isHeartbeatFresh: () => boolean; | ||
| 69 | readonly terminal: { | ||
| 70 | readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>; | ||
| 71 | readonly attach: RpcInputStreamMethod<typeof WS_METHODS.terminalAttach>; | ||
| 72 | readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>; | ||
| 73 | readonly resize: RpcUnaryMethod<typeof WS_METHODS.terminalResize>; | ||
| 74 | readonly clear: RpcUnaryMethod<typeof WS_METHODS.terminalClear>; | ||
| 75 | readonly restart: RpcUnaryMethod<typeof WS_METHODS.terminalRestart>; | ||
| 76 | readonly close: RpcUnaryMethod<typeof WS_METHODS.terminalClose>; | ||
| 77 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalEvents>; | ||
| 78 | readonly onMetadata: RpcStreamMethod<typeof WS_METHODS.subscribeTerminalMetadata>; | ||
| 79 | }; | ||
| 80 | readonly projects: { | ||
| 81 | readonly searchEntries: RpcUnaryMethod<typeof WS_METHODS.projectsSearchEntries>; | ||
| 82 | readonly writeFile: RpcUnaryMethod<typeof WS_METHODS.projectsWriteFile>; | ||
| 83 | }; | ||
| 84 | readonly filesystem: { | ||
| 85 | readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>; | ||
| 86 | }; | ||
| 87 | readonly sourceControl: { | ||
| 88 | readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>; | ||
| 89 | readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>; | ||
| 90 | readonly publishRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlPublishRepository>; | ||
| 91 | }; | ||
| 92 | readonly shell: { | ||
| 93 | readonly openInEditor: (input: { | ||
| 94 | readonly cwd: Parameters<LocalApi["shell"]["openInEditor"]>[0]; | ||
| 95 | readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1]; | ||
| 96 | }) => ReturnType<LocalApi["shell"]["openInEditor"]>; | ||
| 97 | }; | ||
| 98 | readonly vcs: { | ||
| 99 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.vcsPull>; | ||
| 100 | readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.vcsRefreshStatus>; | ||
| 101 | readonly onStatus: ( | ||
| 102 | input: RpcInput<typeof WS_METHODS.subscribeVcsStatus>, | ||
| 103 | listener: (status: VcsStatusResult) => void, | ||
| 104 | options?: StreamSubscriptionOptions, | ||
| 105 | ) => () => void; | ||
| 106 | readonly listRefs: RpcUnaryMethod<typeof WS_METHODS.vcsListRefs>; | ||
| 107 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsCreateWorktree>; | ||
| 108 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsRemoveWorktree>; | ||
| 109 | readonly createRef: RpcUnaryMethod<typeof WS_METHODS.vcsCreateRef>; | ||
| 110 | readonly switchRef: RpcUnaryMethod<typeof WS_METHODS.vcsSwitchRef>; | ||
| 111 | readonly init: RpcUnaryMethod<typeof WS_METHODS.vcsInit>; | ||
| 112 | }; | ||
| 113 | readonly git: { | ||
| 114 | readonly runStackedAction: ( | ||
| 115 | input: GitRunStackedActionInput, | ||
| 116 | options?: GitRunStackedActionOptions, | ||
| 117 | ) => Promise<GitRunStackedActionResult>; | ||
| 118 | readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>; | ||
| 119 | readonly preparePullRequestThread: RpcUnaryMethod< | ||
| 120 | typeof WS_METHODS.gitPreparePullRequestThread | ||
| 121 | >; | ||
| 122 | }; | ||
| 123 | readonly review: { | ||
| 124 | readonly getDiffPreview: RpcUnaryMethod<typeof WS_METHODS.reviewGetDiffPreview>; | ||
| 125 | }; | ||
| 126 | readonly server: { | ||
| 127 | readonly getConfig: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetConfig>; | ||
| 128 | readonly refreshProviders: ( | ||
| 129 | input?: RpcInput<typeof WS_METHODS.serverRefreshProviders>, | ||
| 130 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverRefreshProviders>>; | ||
| 131 | readonly discoverSourceControl: RpcUnaryNoArgMethod< | ||
| 132 | typeof WS_METHODS.serverDiscoverSourceControl | ||
| 133 | >; | ||
| 134 | readonly updateProvider: RpcUnaryMethod<typeof WS_METHODS.serverUpdateProvider>; | ||
| 135 | readonly upsertKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverUpsertKeybinding>; | ||
| 136 | readonly removeKeybinding: RpcUnaryMethod<typeof WS_METHODS.serverRemoveKeybinding>; | ||
| 137 | readonly getSettings: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetSettings>; | ||
| 138 | readonly updateSettings: ( | ||
| 139 | patch: ServerSettingsPatch, | ||
| 140 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>; | ||
| 141 | readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>; | ||
| 142 | readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>; | ||
| 143 | readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>; | ||
| 144 | readonly getTraceDiagnostics: RpcUnaryNoArgMethod<typeof WS_METHODS.serverGetTraceDiagnostics>; | ||
| 145 | readonly getProcessDiagnostics: RpcUnaryNoArgMethod< | ||
| 146 | typeof WS_METHODS.serverGetProcessDiagnostics | ||
| 147 | >; | ||
| 148 | readonly getProcessResourceHistory: RpcUnaryMethod< | ||
| 149 | typeof WS_METHODS.serverGetProcessResourceHistory | ||
| 150 | >; | ||
| 151 | readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>; | ||
| 152 | }; | ||
| 153 | readonly orchestration: { | ||
| 154 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; | ||
| 155 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; | ||
| 156 | readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>; | ||
| 157 | readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod< | ||
| 158 | typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot | ||
| 159 | >; | ||
| 160 | readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>; | ||
| 161 | readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>; | ||
| 162 | }; | ||
| 163 | } | ||
| 164 | |||
| 165 | export interface CreateWsRpcClientOptions { | ||
| 166 | /** Runs immediately before `transport.reconnect()` (e.g. reset reconnect UI/backoff state). */ | ||
| 167 | readonly beforeReconnect?: () => void; | ||
| 168 | } | ||
| 169 | |||
| 170 | export function createWsRpcClient( | ||
| 171 | transport: WsTransport, | ||
| 172 | options?: CreateWsRpcClientOptions, | ||
| 173 | ): WsRpcClient { | ||
| 174 | return { | ||
| 175 | dispose: () => transport.dispose(), | ||
| 176 | isHeartbeatFresh: () => transport.isHeartbeatFresh(), | ||
| 177 | reconnect: async () => { | ||
| 178 | options?.beforeReconnect?.(); | ||
| 179 | await transport.reconnect(); | ||
| 180 | }, | ||
| 181 | terminal: { | ||
| 182 | open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), | ||
| 183 | attach: (input, listener, options) => | ||
| 184 | transport.subscribe( | ||
| 185 | (client) => client[WS_METHODS.terminalAttach](input), | ||
| 186 | listener, | ||
| 187 | subscriptionOptions(options, WS_METHODS.terminalAttach), | ||
| 188 | ), | ||
| 189 | write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), | ||
| 190 | resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), | ||
| 191 | clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), | ||
| 192 | restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), | ||
| 193 | close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), | ||
| 194 | onEvent: (listener, options) => | ||
| 195 | transport.subscribe( | ||
| 196 | (client) => client[WS_METHODS.subscribeTerminalEvents]({}), | ||
| 197 | listener, | ||
| 198 | subscriptionOptions(options, WS_METHODS.subscribeTerminalEvents), | ||
| 199 | ), | ||
| 200 | onMetadata: (listener, options) => | ||
| 201 | transport.subscribe( | ||
| 202 | (client) => client[WS_METHODS.subscribeTerminalMetadata]({}), | ||
| 203 | listener, | ||
| 204 | subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata), | ||
| 205 | ), | ||
| 206 | }, | ||
| 207 | projects: { | ||
| 208 | searchEntries: (input) => | ||
| 209 | transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), | ||
| 210 | writeFile: (input) => | ||
| 211 | transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), | ||
| 212 | }, | ||
| 213 | filesystem: { | ||
| 214 | browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), | ||
| 215 | }, | ||
| 216 | sourceControl: { | ||
| 217 | lookupRepository: (input) => | ||
| 218 | transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), | ||
| 219 | cloneRepository: (input) => | ||
| 220 | transport.request((client) => client[WS_METHODS.sourceControlCloneRepository](input)), | ||
| 221 | publishRepository: (input) => | ||
| 222 | transport.request((client) => client[WS_METHODS.sourceControlPublishRepository](input)), | ||
| 223 | }, | ||
| 224 | shell: { | ||
| 225 | openInEditor: (input) => | ||
| 226 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), | ||
| 227 | }, | ||
| 228 | vcs: { | ||
| 229 | pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)), | ||
| 230 | refreshStatus: (input) => | ||
| 231 | transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)), | ||
| 232 | onStatus: (input, listener, options) => { | ||
| 233 | let current: VcsStatusResult | null = null; | ||
| 234 | return transport.subscribe( | ||
| 235 | (client) => client[WS_METHODS.subscribeVcsStatus](input), | ||
| 236 | (event: VcsStatusStreamEvent) => { | ||
| 237 | current = applyGitStatusStreamEvent(current, event); | ||
| 238 | listener(current); | ||
| 239 | }, | ||
| 240 | subscriptionOptions(options, WS_METHODS.subscribeVcsStatus), | ||
| 241 | ); | ||
| 242 | }, | ||
| 243 | listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), | ||
| 244 | createWorktree: (input) => | ||
| 245 | transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), | ||
| 246 | removeWorktree: (input) => | ||
| 247 | transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), | ||
| 248 | createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), | ||
| 249 | switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), | ||
| 250 | init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), | ||
| 251 | }, | ||
| 252 | git: { | ||
| 253 | runStackedAction: async (input, options) => { | ||
| 254 | let result: GitRunStackedActionResult | null = null; | ||
| 255 | |||
| 256 | await transport.requestStream( | ||
| 257 | (client) => client[WS_METHODS.gitRunStackedAction](input), | ||
| 258 | (event) => { | ||
| 259 | options?.onProgress?.(event); | ||
| 260 | if (event.kind === "action_finished") { | ||
| 261 | result = event.result; | ||
| 262 | } | ||
| 263 | }, | ||
| 264 | ); | ||
| 265 | |||
| 266 | if (result) { | ||
| 267 | return result; | ||
| 268 | } | ||
| 269 | |||
| 270 | throw new Error("Git action stream completed without a final result."); | ||
| 271 | }, | ||
| 272 | resolvePullRequest: (input) => | ||
| 273 | transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), | ||
| 274 | preparePullRequestThread: (input) => | ||
| 275 | transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), | ||
| 276 | }, | ||
| 277 | review: { | ||
| 278 | getDiffPreview: (input) => | ||
| 279 | transport.request((client) => client[WS_METHODS.reviewGetDiffPreview](input)), | ||
| 280 | }, | ||
| 281 | server: { | ||
| 282 | getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), | ||
| 283 | refreshProviders: (input) => | ||
| 284 | transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), | ||
| 285 | discoverSourceControl: () => | ||
| 286 | transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), | ||
| 287 | updateProvider: (input) => | ||
| 288 | transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)), | ||
| 289 | upsertKeybinding: (input) => | ||
| 290 | transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), | ||
| 291 | removeKeybinding: (input) => | ||
| 292 | transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)), | ||
| 293 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), | ||
| 294 | updateSettings: (patch) => | ||
| 295 | transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), | ||
| 296 | subscribeConfig: (listener, options) => | ||
| 297 | transport.subscribe( | ||
| 298 | (client) => client[WS_METHODS.subscribeServerConfig]({}), | ||
| 299 | listener, | ||
| 300 | subscriptionOptions(options, WS_METHODS.subscribeServerConfig), | ||
| 301 | ), | ||
| 302 | subscribeLifecycle: (listener, options) => | ||
| 303 | transport.subscribe( | ||
| 304 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 305 | listener, | ||
| 306 | subscriptionOptions(options, WS_METHODS.subscribeServerLifecycle), | ||
| 307 | ), | ||
| 308 | subscribeAuthAccess: (listener, options) => | ||
| 309 | transport.subscribe( | ||
| 310 | (client) => client[WS_METHODS.subscribeAuthAccess]({}), | ||
| 311 | listener, | ||
| 312 | subscriptionOptions(options, WS_METHODS.subscribeAuthAccess), | ||
| 313 | ), | ||
| 314 | getTraceDiagnostics: () => | ||
| 315 | transport.request((client) => client[WS_METHODS.serverGetTraceDiagnostics]({})), | ||
| 316 | getProcessDiagnostics: () => | ||
| 317 | transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})), | ||
| 318 | getProcessResourceHistory: (input) => | ||
| 319 | transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)), | ||
| 320 | signalProcess: (input) => | ||
| 321 | transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), | ||
| 322 | }, | ||
| 323 | orchestration: { | ||
| 324 | dispatchCommand: (input) => | ||
| 325 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), | ||
| 326 | getTurnDiff: (input) => | ||
| 327 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), | ||
| 328 | getFullThreadDiff: (input) => | ||
| 329 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), | ||
| 330 | getArchivedShellSnapshot: () => | ||
| 331 | transport.request((client) => | ||
| 332 | client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}), | ||
| 333 | ), | ||
| 334 | subscribeShell: (listener, options) => | ||
| 335 | transport.subscribe( | ||
| 336 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), | ||
| 337 | listener, | ||
| 338 | subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeShell), | ||
| 339 | ), | ||
| 340 | subscribeThread: (input, listener, options) => | ||
| 341 | transport.subscribe( | ||
| 342 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), | ||
| 343 | listener, | ||
| 344 | subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeThread), | ||
| 345 | ), | ||
| 346 | }, | ||
| 347 | }; | ||
| 348 | } | ||
packages/client-runtime/src/wsRpcProtocol.ts created+374| ... | @@ -0,0 +1,374 @@ | ||
| 1 | import { WsRpcGroup } from "@t3tools/contracts"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Schedule from "effect/Schedule"; | ||
| 6 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | ||
| 7 | import * as Socket from "effect/unstable/socket/Socket"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | DEFAULT_RECONNECT_BACKOFF, | ||
| 11 | getReconnectDelayMs, | ||
| 12 | type ReconnectBackoffConfig, | ||
| 13 | } from "./reconnectBackoff.ts"; | ||
| 14 | |||
| 15 | export interface WsProtocolLifecycleHandlers { | ||
| 16 | readonly getConnectionLabel?: () => string | null; | ||
| 17 | readonly getVersionMismatchHint?: () => string | null; | ||
| 18 | readonly isCloseIntentional?: () => boolean; | ||
| 19 | readonly isActive?: () => boolean; | ||
| 20 | readonly onAttempt?: (socketUrl: string) => void; | ||
| 21 | readonly onOpen?: () => void; | ||
| 22 | readonly onHeartbeatPing?: () => void; | ||
| 23 | readonly onHeartbeatPong?: () => void; | ||
| 24 | readonly onHeartbeatTimeout?: () => void; | ||
| 25 | readonly onRequestStart?: (info: { | ||
| 26 | readonly id: string; | ||
| 27 | readonly tag: string; | ||
| 28 | readonly stream: boolean; | ||
| 29 | }) => void; | ||
| 30 | readonly onRequestChunk?: (info: { | ||
| 31 | readonly id: string; | ||
| 32 | readonly tag: string; | ||
| 33 | readonly chunkCount: number; | ||
| 34 | }) => void; | ||
| 35 | readonly onRequestExit?: (info: { | ||
| 36 | readonly id: string; | ||
| 37 | readonly tag: string; | ||
| 38 | readonly stream: boolean; | ||
| 39 | }) => void; | ||
| 40 | readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void; | ||
| 41 | readonly onError?: (message: string) => void; | ||
| 42 | readonly onClose?: ( | ||
| 43 | details: { readonly code: number; readonly reason: string }, | ||
| 44 | context: { readonly intentional: boolean }, | ||
| 45 | ) => void; | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface WsRpcProtocolRequestTelemetry { | ||
| 49 | readonly onRequestSent?: (requestId: string, tag: string) => void; | ||
| 50 | readonly onRequestAcknowledged?: (requestId: string) => void; | ||
| 51 | readonly onClearTrackedRequests?: () => void; | ||
| 52 | } | ||
| 53 | |||
| 54 | export interface WsRpcProtocolOptions { | ||
| 55 | /** Backoff configuration for reconnect retries. */ | ||
| 56 | readonly backoff?: ReconnectBackoffConfig; | ||
| 57 | /** | ||
| 58 | * Invoked before user {@link WsProtocolLifecycleHandlers} for each socket lifecycle event. | ||
| 59 | * Use for additive telemetry (connection state, clearing request trackers on disconnect). | ||
| 60 | */ | ||
| 61 | readonly telemetryLifecycle?: WsProtocolLifecycleHandlers; | ||
| 62 | /** Optional hooks around outbound requests and inbound RPC responses (latency tracking, etc.). */ | ||
| 63 | readonly requestTelemetry?: WsRpcProtocolRequestTelemetry; | ||
| 64 | } | ||
| 65 | |||
| 66 | export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); | ||
| 67 | type RpcClientFactory = typeof makeWsRpcProtocolClient; | ||
| 68 | export type WsRpcProtocolClient = | ||
| 69 | RpcClientFactory extends Effect.Effect<infer Client, any, any> ? Client : never; | ||
| 70 | export type WsRpcProtocolSocketUrlProvider = string | (() => Promise<string>); | ||
| 71 | |||
| 72 | function formatSocketErrorMessage(error: unknown): string { | ||
| 73 | if (error instanceof Error && error.message.trim().length > 0) { | ||
| 74 | return error.message; | ||
| 75 | } | ||
| 76 | |||
| 77 | return String(error); | ||
| 78 | } | ||
| 79 | |||
| 80 | function resolveWsRpcSocketUrl(rawUrl: string): string { | ||
| 81 | const resolved = new URL(rawUrl); | ||
| 82 | if (resolved.protocol !== "ws:" && resolved.protocol !== "wss:") { | ||
| 83 | throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); | ||
| 84 | } | ||
| 85 | |||
| 86 | resolved.pathname = "/ws"; | ||
| 87 | return resolved.toString(); | ||
| 88 | } | ||
| 89 | |||
| 90 | type ResolvedLifecycleHandlers = Required< | ||
| 91 | Pick< | ||
| 92 | WsProtocolLifecycleHandlers, | ||
| 93 | | "getConnectionLabel" | ||
| 94 | | "getVersionMismatchHint" | ||
| 95 | | "isCloseIntentional" | ||
| 96 | | "isActive" | ||
| 97 | | "onAttempt" | ||
| 98 | | "onOpen" | ||
| 99 | | "onHeartbeatPing" | ||
| 100 | | "onHeartbeatPong" | ||
| 101 | | "onHeartbeatTimeout" | ||
| 102 | | "onError" | ||
| 103 | | "onClose" | ||
| 104 | > | ||
| 105 | >; | ||
| 106 | |||
| 107 | function defaultLifecycleHandlers(): ResolvedLifecycleHandlers { | ||
| 108 | return { | ||
| 109 | onAttempt: () => undefined, | ||
| 110 | onOpen: () => undefined, | ||
| 111 | onHeartbeatPing: () => undefined, | ||
| 112 | onHeartbeatPong: () => undefined, | ||
| 113 | onHeartbeatTimeout: () => undefined, | ||
| 114 | onError: () => undefined, | ||
| 115 | onClose: () => undefined, | ||
| 116 | getConnectionLabel: () => null, | ||
| 117 | getVersionMismatchHint: () => null, | ||
| 118 | isCloseIntentional: () => false, | ||
| 119 | isActive: () => true, | ||
| 120 | }; | ||
| 121 | } | ||
| 122 | |||
| 123 | function resolveLifecycleHandlers( | ||
| 124 | handlers: WsProtocolLifecycleHandlers | undefined, | ||
| 125 | telemetryLifecycle: WsProtocolLifecycleHandlers | undefined, | ||
| 126 | ): ResolvedLifecycleHandlers { | ||
| 127 | const defaults = defaultLifecycleHandlers(); | ||
| 128 | const isActive = handlers?.isActive ?? telemetryLifecycle?.isActive ?? defaults.isActive; | ||
| 129 | const isCloseIntentional = | ||
| 130 | handlers?.isCloseIntentional ?? | ||
| 131 | telemetryLifecycle?.isCloseIntentional ?? | ||
| 132 | defaults.isCloseIntentional; | ||
| 133 | |||
| 134 | return { | ||
| 135 | getConnectionLabel: () => | ||
| 136 | handlers?.getConnectionLabel?.() ?? telemetryLifecycle?.getConnectionLabel?.() ?? null, | ||
| 137 | getVersionMismatchHint: () => | ||
| 138 | handlers?.getVersionMismatchHint?.() ?? | ||
| 139 | telemetryLifecycle?.getVersionMismatchHint?.() ?? | ||
| 140 | null, | ||
| 141 | isActive, | ||
| 142 | isCloseIntentional, | ||
| 143 | onAttempt: (socketUrl) => { | ||
| 144 | if (!isActive()) { | ||
| 145 | return; | ||
| 146 | } | ||
| 147 | telemetryLifecycle?.onAttempt?.(socketUrl); | ||
| 148 | handlers?.onAttempt?.(socketUrl); | ||
| 149 | }, | ||
| 150 | onOpen: () => { | ||
| 151 | if (!isActive()) { | ||
| 152 | return; | ||
| 153 | } | ||
| 154 | telemetryLifecycle?.onOpen?.(); | ||
| 155 | handlers?.onOpen?.(); | ||
| 156 | }, | ||
| 157 | onHeartbeatPing: () => { | ||
| 158 | if (!isActive()) { | ||
| 159 | return; | ||
| 160 | } | ||
| 161 | telemetryLifecycle?.onHeartbeatPing?.(); | ||
| 162 | handlers?.onHeartbeatPing?.(); | ||
| 163 | }, | ||
| 164 | onHeartbeatPong: () => { | ||
| 165 | if (!isActive()) { | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | telemetryLifecycle?.onHeartbeatPong?.(); | ||
| 169 | handlers?.onHeartbeatPong?.(); | ||
| 170 | }, | ||
| 171 | onHeartbeatTimeout: () => { | ||
| 172 | if (!isActive()) { | ||
| 173 | return; | ||
| 174 | } | ||
| 175 | telemetryLifecycle?.onHeartbeatTimeout?.(); | ||
| 176 | handlers?.onHeartbeatTimeout?.(); | ||
| 177 | }, | ||
| 178 | onError: (message) => { | ||
| 179 | if (!isActive()) { | ||
| 180 | return; | ||
| 181 | } | ||
| 182 | telemetryLifecycle?.onError?.(message); | ||
| 183 | handlers?.onError?.(message); | ||
| 184 | }, | ||
| 185 | onClose: (details, context) => { | ||
| 186 | if (!isActive()) { | ||
| 187 | return; | ||
| 188 | } | ||
| 189 | telemetryLifecycle?.onClose?.(details, context); | ||
| 190 | handlers?.onClose?.(details, context); | ||
| 191 | }, | ||
| 192 | }; | ||
| 193 | } | ||
| 194 | |||
| 195 | export function createWsRpcProtocolLayer( | ||
| 196 | url: WsRpcProtocolSocketUrlProvider, | ||
| 197 | handlers?: WsProtocolLifecycleHandlers, | ||
| 198 | options?: WsRpcProtocolOptions, | ||
| 199 | ) { | ||
| 200 | const lifecycle = resolveLifecycleHandlers(handlers, options?.telemetryLifecycle); | ||
| 201 | const backoff = options?.backoff ?? DEFAULT_RECONNECT_BACKOFF; | ||
| 202 | const requestTelemetry = options?.requestTelemetry; | ||
| 203 | const instrumentRequests = | ||
| 204 | requestTelemetry?.onRequestSent !== undefined || | ||
| 205 | requestTelemetry?.onRequestAcknowledged !== undefined || | ||
| 206 | requestTelemetry?.onClearTrackedRequests !== undefined; | ||
| 207 | |||
| 208 | const resolvedUrl = | ||
| 209 | typeof url === "function" | ||
| 210 | ? Effect.promise(() => url()).pipe( | ||
| 211 | Effect.map((rawUrl) => resolveWsRpcSocketUrl(rawUrl)), | ||
| 212 | Effect.tapError((error) => | ||
| 213 | Effect.sync(() => { | ||
| 214 | lifecycle.onError(formatSocketErrorMessage(error)); | ||
| 215 | }), | ||
| 216 | ), | ||
| 217 | Effect.orDie, | ||
| 218 | ) | ||
| 219 | : resolveWsRpcSocketUrl(url); | ||
| 220 | |||
| 221 | const trackingWebSocketConstructorLayer = Layer.succeed( | ||
| 222 | Socket.WebSocketConstructor, | ||
| 223 | (socketUrl, protocols) => { | ||
| 224 | lifecycle.onAttempt(socketUrl); | ||
| 225 | const socket = new globalThis.WebSocket(socketUrl, protocols); | ||
| 226 | |||
| 227 | socket.addEventListener( | ||
| 228 | "open", | ||
| 229 | () => { | ||
| 230 | lifecycle.onOpen(); | ||
| 231 | }, | ||
| 232 | { once: true }, | ||
| 233 | ); | ||
| 234 | socket.addEventListener( | ||
| 235 | "error", | ||
| 236 | () => { | ||
| 237 | lifecycle.onError("Unable to connect to the T3 server WebSocket."); | ||
| 238 | }, | ||
| 239 | { once: true }, | ||
| 240 | ); | ||
| 241 | socket.addEventListener( | ||
| 242 | "close", | ||
| 243 | (event) => { | ||
| 244 | lifecycle.onClose( | ||
| 245 | { | ||
| 246 | code: event.code, | ||
| 247 | reason: event.reason, | ||
| 248 | }, | ||
| 249 | { | ||
| 250 | intentional: lifecycle.isCloseIntentional(), | ||
| 251 | }, | ||
| 252 | ); | ||
| 253 | }, | ||
| 254 | { once: true }, | ||
| 255 | ); | ||
| 256 | |||
| 257 | return socket; | ||
| 258 | }, | ||
| 259 | ); | ||
| 260 | const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe( | ||
| 261 | Layer.provide(trackingWebSocketConstructorLayer), | ||
| 262 | ); | ||
| 263 | |||
| 264 | const baseSchedule = | ||
| 265 | backoff.maxRetries === null ? Schedule.forever : Schedule.recurs(backoff.maxRetries); | ||
| 266 | const retryPolicy = Schedule.addDelay(baseSchedule, (retryCount) => | ||
| 267 | Effect.succeed(Duration.millis(getReconnectDelayMs(retryCount, backoff) ?? 0)), | ||
| 268 | ); | ||
| 269 | const protocolLayer = Layer.effect( | ||
| 270 | RpcClient.Protocol, | ||
| 271 | instrumentRequests | ||
| 272 | ? Effect.map( | ||
| 273 | RpcClient.makeProtocolSocket({ | ||
| 274 | retryPolicy, | ||
| 275 | retryTransientErrors: true, | ||
| 276 | }), | ||
| 277 | (protocol) => ({ | ||
| 278 | ...protocol, | ||
| 279 | run: (clientId, writeResponse) => | ||
| 280 | protocol.run(clientId, (response) => { | ||
| 281 | if (response._tag === "Chunk" || response._tag === "Exit") { | ||
| 282 | requestTelemetry?.onRequestAcknowledged?.(response.requestId); | ||
| 283 | } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") { | ||
| 284 | requestTelemetry?.onClearTrackedRequests?.(); | ||
| 285 | } | ||
| 286 | return writeResponse(response); | ||
| 287 | }), | ||
| 288 | send: (clientId, request, transferables) => { | ||
| 289 | if (request._tag === "Request") { | ||
| 290 | requestTelemetry?.onRequestSent?.(request.id, request.tag); | ||
| 291 | } | ||
| 292 | return protocol.send(clientId, request, transferables); | ||
| 293 | }, | ||
| 294 | }), | ||
| 295 | ) | ||
| 296 | : RpcClient.makeProtocolSocket({ | ||
| 297 | retryPolicy, | ||
| 298 | retryTransientErrors: true, | ||
| 299 | }), | ||
| 300 | ); | ||
| 301 | const requestHooksLayer = Layer.succeed( | ||
| 302 | RpcClient.RequestHooks, | ||
| 303 | RpcClient.RequestHooks.of({ | ||
| 304 | onRequestStart: (info) => | ||
| 305 | Effect.sync(() => { | ||
| 306 | if (!lifecycle.isActive()) { | ||
| 307 | return; | ||
| 308 | } | ||
| 309 | handlers?.onRequestStart?.({ | ||
| 310 | id: String(info.id), | ||
| 311 | tag: info.tag, | ||
| 312 | stream: info.stream, | ||
| 313 | }); | ||
| 314 | }), | ||
| 315 | onRequestChunk: (info) => | ||
| 316 | Effect.sync(() => { | ||
| 317 | if (!lifecycle.isActive()) { | ||
| 318 | return; | ||
| 319 | } | ||
| 320 | handlers?.onRequestChunk?.({ | ||
| 321 | id: String(info.id), | ||
| 322 | tag: info.tag, | ||
| 323 | chunkCount: info.chunkCount, | ||
| 324 | }); | ||
| 325 | }), | ||
| 326 | onRequestExit: (info) => | ||
| 327 | Effect.sync(() => { | ||
| 328 | if (!lifecycle.isActive()) { | ||
| 329 | return; | ||
| 330 | } | ||
| 331 | handlers?.onRequestExit?.({ | ||
| 332 | id: String(info.id), | ||
| 333 | tag: info.tag, | ||
| 334 | stream: info.stream, | ||
| 335 | }); | ||
| 336 | }), | ||
| 337 | onRequestInterrupt: (info) => | ||
| 338 | Effect.sync(() => { | ||
| 339 | if (!lifecycle.isActive()) { | ||
| 340 | return; | ||
| 341 | } | ||
| 342 | handlers?.onRequestInterrupt?.({ | ||
| 343 | id: String(info.id), | ||
| 344 | ...(info.tag === undefined ? {} : { tag: info.tag }), | ||
| 345 | }); | ||
| 346 | }), | ||
| 347 | }), | ||
| 348 | ); | ||
| 349 | const connectionHooksLayer = Layer.succeed( | ||
| 350 | RpcClient.ConnectionHooks, | ||
| 351 | RpcClient.ConnectionHooks.of({ | ||
| 352 | onConnect: Effect.void, | ||
| 353 | onDisconnect: Effect.void, | ||
| 354 | onPing: Effect.sync(() => { | ||
| 355 | lifecycle.onHeartbeatPing(); | ||
| 356 | }), | ||
| 357 | onPong: Effect.sync(() => { | ||
| 358 | lifecycle.onHeartbeatPong(); | ||
| 359 | }), | ||
| 360 | onPingTimeout: Effect.sync(() => { | ||
| 361 | requestTelemetry?.onClearTrackedRequests?.(); | ||
| 362 | lifecycle.onHeartbeatTimeout(); | ||
| 363 | }), | ||
| 364 | }), | ||
| 365 | ); | ||
| 366 | |||
| 367 | return Layer.mergeAll( | ||
| 368 | protocolLayer.pipe( | ||
| 369 | Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)), | ||
| 370 | ), | ||
| 371 | requestHooksLayer, | ||
| 372 | connectionHooksLayer, | ||
| 373 | ); | ||
| 374 | } | ||
packages/client-runtime/src/wsTransport.test.ts created+959| ... | @@ -0,0 +1,959 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Stream from "effect/Stream"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| 6 | |||
| 7 | import { WsTransport } from "./wsTransport.ts"; | ||
| 8 | |||
| 9 | type WsEventType = "open" | "message" | "close" | "error"; | ||
| 10 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; | ||
| 11 | type WsListener = (event?: WsEvent) => void; | ||
| 12 | |||
| 13 | const sockets: MockWebSocket[] = []; | ||
| 14 | |||
| 15 | class MockWebSocket { | ||
| 16 | static readonly CONNECTING = 0; | ||
| 17 | static readonly OPEN = 1; | ||
| 18 | static readonly CLOSING = 2; | ||
| 19 | static readonly CLOSED = 3; | ||
| 20 | |||
| 21 | readyState = MockWebSocket.CONNECTING; | ||
| 22 | readonly sent: string[] = []; | ||
| 23 | readonly url: string; | ||
| 24 | private readonly listeners = new Map<WsEventType, Set<WsListener>>(); | ||
| 25 | |||
| 26 | constructor(url: string) { | ||
| 27 | this.url = url; | ||
| 28 | sockets.push(this); | ||
| 29 | } | ||
| 30 | |||
| 31 | addEventListener(type: WsEventType, listener: WsListener) { | ||
| 32 | const listeners = this.listeners.get(type) ?? new Set<WsListener>(); | ||
| 33 | listeners.add(listener); | ||
| 34 | this.listeners.set(type, listeners); | ||
| 35 | } | ||
| 36 | |||
| 37 | removeEventListener(type: WsEventType, listener: WsListener) { | ||
| 38 | this.listeners.get(type)?.delete(listener); | ||
| 39 | } | ||
| 40 | |||
| 41 | send(data: string) { | ||
| 42 | this.sent.push(data); | ||
| 43 | } | ||
| 44 | |||
| 45 | close(code = 1000, reason = "") { | ||
| 46 | this.readyState = MockWebSocket.CLOSED; | ||
| 47 | this.emit("close", { code, reason, type: "close" }); | ||
| 48 | } | ||
| 49 | |||
| 50 | open() { | ||
| 51 | this.readyState = MockWebSocket.OPEN; | ||
| 52 | this.emit("open", { type: "open" }); | ||
| 53 | } | ||
| 54 | |||
| 55 | serverMessage(data: unknown) { | ||
| 56 | this.emit("message", { data, type: "message" }); | ||
| 57 | } | ||
| 58 | |||
| 59 | error() { | ||
| 60 | this.emit("error", { type: "error" }); | ||
| 61 | } | ||
| 62 | |||
| 63 | private emit(type: WsEventType, event?: WsEvent) { | ||
| 64 | const listeners = this.listeners.get(type); | ||
| 65 | if (!listeners) return; | ||
| 66 | for (const listener of listeners) { | ||
| 67 | listener(event); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | const originalWebSocket = globalThis.WebSocket; | ||
| 73 | const originalFetch = globalThis.fetch; | ||
| 74 | const transports: WsTransport[] = []; | ||
| 75 | |||
| 76 | function getSocket(): MockWebSocket { | ||
| 77 | const socket = sockets.at(-1); | ||
| 78 | if (!socket) { | ||
| 79 | throw new Error("Expected a websocket instance"); | ||
| 80 | } | ||
| 81 | return socket; | ||
| 82 | } | ||
| 83 | |||
| 84 | async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { | ||
| 85 | const startedAt = performance.now(); | ||
| 86 | for (;;) { | ||
| 87 | try { | ||
| 88 | assertion(); | ||
| 89 | return; | ||
| 90 | } catch (error) { | ||
| 91 | if (performance.now() - startedAt >= timeoutMs) { | ||
| 92 | throw error; | ||
| 93 | } | ||
| 94 | await Effect.runPromise(Effect.sleep(Duration.millis(10))); | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | function createTransport(...args: ConstructorParameters<typeof WsTransport>): WsTransport { | ||
| 100 | const transport = new WsTransport(...args); | ||
| 101 | transports.push(transport); | ||
| 102 | return transport; | ||
| 103 | } | ||
| 104 | |||
| 105 | beforeEach(() => { | ||
| 106 | vi.useRealTimers(); | ||
| 107 | sockets.length = 0; | ||
| 108 | transports.length = 0; | ||
| 109 | |||
| 110 | Object.defineProperty(globalThis, "window", { | ||
| 111 | configurable: true, | ||
| 112 | value: { | ||
| 113 | location: { | ||
| 114 | origin: "http://localhost:3020", | ||
| 115 | hostname: "localhost", | ||
| 116 | port: "3020", | ||
| 117 | protocol: "http:", | ||
| 118 | }, | ||
| 119 | desktopBridge: undefined, | ||
| 120 | }, | ||
| 121 | }); | ||
| 122 | Object.defineProperty(globalThis, "navigator", { | ||
| 123 | configurable: true, | ||
| 124 | value: { onLine: true }, | ||
| 125 | }); | ||
| 126 | |||
| 127 | globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; | ||
| 128 | }); | ||
| 129 | |||
| 130 | afterEach(async () => { | ||
| 131 | await Promise.allSettled(transports.map((transport) => transport.dispose())); | ||
| 132 | transports.length = 0; | ||
| 133 | globalThis.WebSocket = originalWebSocket; | ||
| 134 | globalThis.fetch = originalFetch; | ||
| 135 | vi.restoreAllMocks(); | ||
| 136 | }); | ||
| 137 | |||
| 138 | describe("WsTransport", () => { | ||
| 139 | it("normalizes root websocket urls to /ws and preserves query params", async () => { | ||
| 140 | const transport = createTransport("ws://localhost:3020/?token=secret-token"); | ||
| 141 | |||
| 142 | await waitFor(() => { | ||
| 143 | expect(sockets).toHaveLength(1); | ||
| 144 | }); | ||
| 145 | |||
| 146 | expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); | ||
| 147 | await transport.dispose(); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("uses an explicit secure websocket base url", async () => { | ||
| 151 | const transport = createTransport("wss://app.example.com"); | ||
| 152 | |||
| 153 | await waitFor(() => { | ||
| 154 | expect(sockets).toHaveLength(1); | ||
| 155 | }); | ||
| 156 | |||
| 157 | expect(getSocket().url).toBe("wss://app.example.com/ws"); | ||
| 158 | await transport.dispose(); | ||
| 159 | }); | ||
| 160 | |||
| 161 | it("uses an explicit insecure websocket base url for remote backends", async () => { | ||
| 162 | const transport = createTransport("ws://192.168.1.44:3773"); | ||
| 163 | |||
| 164 | await waitFor(() => { | ||
| 165 | expect(sockets).toHaveLength(1); | ||
| 166 | }); | ||
| 167 | |||
| 168 | expect(getSocket().url).toBe("ws://192.168.1.44:3773/ws"); | ||
| 169 | await transport.dispose(); | ||
| 170 | }); | ||
| 171 | |||
| 172 | it("supports async websocket url providers", async () => { | ||
| 173 | const transport = createTransport(async () => "wss://remote.example.com/?wsToken=dynamic"); | ||
| 174 | |||
| 175 | await waitFor(() => { | ||
| 176 | expect(sockets).toHaveLength(1); | ||
| 177 | }); | ||
| 178 | |||
| 179 | expect(getSocket().url).toBe("wss://remote.example.com/ws?wsToken=dynamic"); | ||
| 180 | await transport.dispose(); | ||
| 181 | }); | ||
| 182 | |||
| 183 | it("invokes optional lifecycle handlers when the socket opens and closes", async () => { | ||
| 184 | const onOpen = vi.fn(); | ||
| 185 | const onClose = vi.fn(); | ||
| 186 | const transport = createTransport("ws://localhost:3020", { | ||
| 187 | onOpen, | ||
| 188 | onClose, | ||
| 189 | }); | ||
| 190 | |||
| 191 | await waitFor(() => { | ||
| 192 | expect(sockets).toHaveLength(1); | ||
| 193 | }); | ||
| 194 | |||
| 195 | const socket = getSocket(); | ||
| 196 | socket.open(); | ||
| 197 | |||
| 198 | await waitFor(() => { | ||
| 199 | expect(onOpen).toHaveBeenCalledOnce(); | ||
| 200 | }); | ||
| 201 | |||
| 202 | socket.close(1012, "service restart"); | ||
| 203 | |||
| 204 | await waitFor(() => { | ||
| 205 | expect(onClose).toHaveBeenCalledWith( | ||
| 206 | { | ||
| 207 | code: 1012, | ||
| 208 | reason: "service restart", | ||
| 209 | }, | ||
| 210 | { | ||
| 211 | intentional: false, | ||
| 212 | }, | ||
| 213 | ); | ||
| 214 | }); | ||
| 215 | |||
| 216 | await transport.dispose(); | ||
| 217 | }); | ||
| 218 | |||
| 219 | it("tracks heartbeat freshness from websocket pongs", async () => { | ||
| 220 | const nowSpy = vi.spyOn(performance, "now").mockReturnValue(1_000); | ||
| 221 | const onHeartbeatPong = vi.fn(); | ||
| 222 | const transport = createTransport("ws://localhost:3020", { onHeartbeatPong }); | ||
| 223 | |||
| 224 | await waitFor(() => { | ||
| 225 | expect(sockets).toHaveLength(1); | ||
| 226 | }); | ||
| 227 | |||
| 228 | expect(transport.isHeartbeatFresh()).toBe(false); | ||
| 229 | |||
| 230 | const socket = getSocket(); | ||
| 231 | socket.open(); | ||
| 232 | socket.serverMessage(JSON.stringify({ _tag: "Pong" })); | ||
| 233 | |||
| 234 | await waitFor(() => { | ||
| 235 | expect(onHeartbeatPong).toHaveBeenCalledOnce(); | ||
| 236 | }); | ||
| 237 | |||
| 238 | expect(transport.isHeartbeatFresh()).toBe(true); | ||
| 239 | expect(transport.isHeartbeatFresh(500)).toBe(true); | ||
| 240 | |||
| 241 | nowSpy.mockReturnValue(1_501); | ||
| 242 | expect(transport.isHeartbeatFresh(500)).toBe(false); | ||
| 243 | |||
| 244 | await transport.dispose(); | ||
| 245 | }); | ||
| 246 | |||
| 247 | it("clears heartbeat freshness when reconnecting", async () => { | ||
| 248 | vi.spyOn(performance, "now").mockReturnValue(1_000); | ||
| 249 | const onHeartbeatPong = vi.fn(); | ||
| 250 | const transport = createTransport("ws://localhost:3020", { onHeartbeatPong }); | ||
| 251 | |||
| 252 | await waitFor(() => { | ||
| 253 | expect(sockets).toHaveLength(1); | ||
| 254 | }); | ||
| 255 | |||
| 256 | const firstSocket = getSocket(); | ||
| 257 | firstSocket.open(); | ||
| 258 | firstSocket.serverMessage(JSON.stringify({ _tag: "Pong" })); | ||
| 259 | |||
| 260 | await waitFor(() => { | ||
| 261 | expect(onHeartbeatPong).toHaveBeenCalledOnce(); | ||
| 262 | }); | ||
| 263 | expect(transport.isHeartbeatFresh()).toBe(true); | ||
| 264 | |||
| 265 | await transport.reconnect(); | ||
| 266 | |||
| 267 | expect(transport.isHeartbeatFresh()).toBe(false); | ||
| 268 | |||
| 269 | await transport.dispose(); | ||
| 270 | }); | ||
| 271 | |||
| 272 | it("does not report an intentional dispose as a close", async () => { | ||
| 273 | const onClose = vi.fn(); | ||
| 274 | const transport = createTransport("ws://localhost:3020", { onClose }); | ||
| 275 | |||
| 276 | await waitFor(() => { | ||
| 277 | expect(sockets).toHaveLength(1); | ||
| 278 | }); | ||
| 279 | |||
| 280 | getSocket().open(); | ||
| 281 | await transport.dispose(); | ||
| 282 | |||
| 283 | expect(onClose).not.toHaveBeenCalled(); | ||
| 284 | }); | ||
| 285 | |||
| 286 | it("ignores stale socket lifecycle events after reconnect starts a new session", async () => { | ||
| 287 | const onClose = vi.fn(); | ||
| 288 | const transport = createTransport("ws://localhost:3020", { onClose }); | ||
| 289 | |||
| 290 | await waitFor(() => { | ||
| 291 | expect(sockets).toHaveLength(1); | ||
| 292 | }); | ||
| 293 | |||
| 294 | const firstSocket = getSocket(); | ||
| 295 | firstSocket.open(); | ||
| 296 | |||
| 297 | await transport.reconnect(); | ||
| 298 | |||
| 299 | await waitFor(() => { | ||
| 300 | expect(sockets).toHaveLength(2); | ||
| 301 | }); | ||
| 302 | |||
| 303 | firstSocket.close(1006, "stale close"); | ||
| 304 | |||
| 305 | expect(onClose).not.toHaveBeenCalled(); | ||
| 306 | |||
| 307 | await transport.dispose(); | ||
| 308 | }); | ||
| 309 | |||
| 310 | it("reconnects the websocket session without disposing the transport", async () => { | ||
| 311 | const transport = createTransport("ws://localhost:3020"); | ||
| 312 | |||
| 313 | await waitFor(() => { | ||
| 314 | expect(sockets).toHaveLength(1); | ||
| 315 | }); | ||
| 316 | |||
| 317 | const firstSocket = getSocket(); | ||
| 318 | firstSocket.open(); | ||
| 319 | |||
| 320 | await transport.reconnect(); | ||
| 321 | |||
| 322 | await waitFor(() => { | ||
| 323 | expect(sockets).toHaveLength(2); | ||
| 324 | }); | ||
| 325 | |||
| 326 | const secondSocket = getSocket(); | ||
| 327 | expect(secondSocket).not.toBe(firstSocket); | ||
| 328 | expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); | ||
| 329 | |||
| 330 | const requestPromise = transport.request((client) => | ||
| 331 | client[WS_METHODS.serverUpsertKeybinding]({ | ||
| 332 | command: "terminal.toggle", | ||
| 333 | key: "ctrl+k", | ||
| 334 | }), | ||
| 335 | ); | ||
| 336 | |||
| 337 | secondSocket.open(); | ||
| 338 | |||
| 339 | await waitFor(() => { | ||
| 340 | expect(secondSocket.sent).toHaveLength(1); | ||
| 341 | }); | ||
| 342 | |||
| 343 | const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string }; | ||
| 344 | secondSocket.serverMessage( | ||
| 345 | JSON.stringify({ | ||
| 346 | _tag: "Exit", | ||
| 347 | requestId: requestMessage.id, | ||
| 348 | exit: { | ||
| 349 | _tag: "Success", | ||
| 350 | value: { | ||
| 351 | keybindings: [], | ||
| 352 | issues: [], | ||
| 353 | }, | ||
| 354 | }, | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | await expect(requestPromise).resolves.toEqual({ | ||
| 359 | keybindings: [], | ||
| 360 | issues: [], | ||
| 361 | }); | ||
| 362 | |||
| 363 | await transport.dispose(); | ||
| 364 | }); | ||
| 365 | |||
| 366 | it("sends unary RPC requests and resolves successful exits", async () => { | ||
| 367 | const transport = createTransport("ws://localhost:3020"); | ||
| 368 | |||
| 369 | const requestPromise = transport.request((client) => | ||
| 370 | client[WS_METHODS.serverUpsertKeybinding]({ | ||
| 371 | command: "terminal.toggle", | ||
| 372 | key: "ctrl+k", | ||
| 373 | }), | ||
| 374 | ); | ||
| 375 | |||
| 376 | await waitFor(() => { | ||
| 377 | expect(sockets).toHaveLength(1); | ||
| 378 | }); | ||
| 379 | |||
| 380 | const socket = getSocket(); | ||
| 381 | socket.open(); | ||
| 382 | |||
| 383 | await waitFor(() => { | ||
| 384 | expect(socket.sent).toHaveLength(1); | ||
| 385 | }); | ||
| 386 | |||
| 387 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | ||
| 388 | _tag: string; | ||
| 389 | id: string; | ||
| 390 | payload: unknown; | ||
| 391 | tag: string; | ||
| 392 | }; | ||
| 393 | expect(requestMessage).toMatchObject({ | ||
| 394 | _tag: "Request", | ||
| 395 | tag: WS_METHODS.serverUpsertKeybinding, | ||
| 396 | payload: { | ||
| 397 | command: "terminal.toggle", | ||
| 398 | key: "ctrl+k", | ||
| 399 | }, | ||
| 400 | }); | ||
| 401 | |||
| 402 | socket.serverMessage( | ||
| 403 | JSON.stringify({ | ||
| 404 | _tag: "Exit", | ||
| 405 | requestId: requestMessage.id, | ||
| 406 | exit: { | ||
| 407 | _tag: "Success", | ||
| 408 | value: { | ||
| 409 | keybindings: [], | ||
| 410 | issues: [], | ||
| 411 | }, | ||
| 412 | }, | ||
| 413 | }), | ||
| 414 | ); | ||
| 415 | |||
| 416 | await expect(requestPromise).resolves.toEqual({ | ||
| 417 | keybindings: [], | ||
| 418 | issues: [], | ||
| 419 | }); | ||
| 420 | |||
| 421 | await transport.dispose(); | ||
| 422 | }); | ||
| 423 | |||
| 424 | it("delivers stream chunks to subscribers", async () => { | ||
| 425 | const transport = createTransport("ws://localhost:3020"); | ||
| 426 | const listener = vi.fn(); | ||
| 427 | |||
| 428 | const unsubscribe = transport.subscribe( | ||
| 429 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 430 | listener, | ||
| 431 | ); | ||
| 432 | await waitFor(() => { | ||
| 433 | expect(sockets).toHaveLength(1); | ||
| 434 | }); | ||
| 435 | |||
| 436 | const socket = getSocket(); | ||
| 437 | socket.open(); | ||
| 438 | |||
| 439 | await waitFor(() => { | ||
| 440 | expect(socket.sent).toHaveLength(1); | ||
| 441 | }); | ||
| 442 | |||
| 443 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | ||
| 444 | expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); | ||
| 445 | |||
| 446 | const welcomeEvent = { | ||
| 447 | version: 1, | ||
| 448 | sequence: 1, | ||
| 449 | type: "welcome", | ||
| 450 | payload: { | ||
| 451 | environment: { | ||
| 452 | environmentId: "environment-local", | ||
| 453 | label: "Local environment", | ||
| 454 | platform: { os: "darwin", arch: "arm64" }, | ||
| 455 | serverVersion: "0.0.0-test", | ||
| 456 | capabilities: { repositoryIdentity: true }, | ||
| 457 | }, | ||
| 458 | cwd: "/tmp/workspace", | ||
| 459 | projectName: "workspace", | ||
| 460 | }, | ||
| 461 | }; | ||
| 462 | |||
| 463 | socket.serverMessage( | ||
| 464 | JSON.stringify({ | ||
| 465 | _tag: "Chunk", | ||
| 466 | requestId: requestMessage.id, | ||
| 467 | values: [welcomeEvent], | ||
| 468 | }), | ||
| 469 | ); | ||
| 470 | |||
| 471 | await waitFor(() => { | ||
| 472 | expect(listener).toHaveBeenCalledWith(welcomeEvent); | ||
| 473 | }); | ||
| 474 | |||
| 475 | unsubscribe(); | ||
| 476 | await transport.dispose(); | ||
| 477 | }); | ||
| 478 | |||
| 479 | it("re-subscribes stream listeners after the stream exits", async () => { | ||
| 480 | const transport = createTransport("ws://localhost:3020"); | ||
| 481 | const listener = vi.fn(); | ||
| 482 | const onResubscribe = vi.fn(); | ||
| 483 | |||
| 484 | const unsubscribe = transport.subscribe( | ||
| 485 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 486 | listener, | ||
| 487 | { onResubscribe }, | ||
| 488 | ); | ||
| 489 | await waitFor(() => { | ||
| 490 | expect(sockets).toHaveLength(1); | ||
| 491 | }); | ||
| 492 | |||
| 493 | const socket = getSocket(); | ||
| 494 | socket.open(); | ||
| 495 | |||
| 496 | await waitFor(() => { | ||
| 497 | expect(socket.sent).toHaveLength(1); | ||
| 498 | }); | ||
| 499 | |||
| 500 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 501 | socket.serverMessage( | ||
| 502 | JSON.stringify({ | ||
| 503 | _tag: "Chunk", | ||
| 504 | requestId: firstRequest.id, | ||
| 505 | values: [ | ||
| 506 | { | ||
| 507 | version: 1, | ||
| 508 | sequence: 1, | ||
| 509 | type: "welcome", | ||
| 510 | payload: { | ||
| 511 | environment: { | ||
| 512 | environmentId: "environment-local", | ||
| 513 | label: "Local environment", | ||
| 514 | platform: { os: "darwin", arch: "arm64" }, | ||
| 515 | serverVersion: "0.0.0-test", | ||
| 516 | capabilities: { repositoryIdentity: true }, | ||
| 517 | }, | ||
| 518 | cwd: "/tmp/one", | ||
| 519 | projectName: "one", | ||
| 520 | }, | ||
| 521 | }, | ||
| 522 | ], | ||
| 523 | }), | ||
| 524 | ); | ||
| 525 | socket.serverMessage( | ||
| 526 | JSON.stringify({ | ||
| 527 | _tag: "Exit", | ||
| 528 | requestId: firstRequest.id, | ||
| 529 | exit: { | ||
| 530 | _tag: "Success", | ||
| 531 | value: null, | ||
| 532 | }, | ||
| 533 | }), | ||
| 534 | ); | ||
| 535 | |||
| 536 | await waitFor(() => { | ||
| 537 | const nextRequest = socket.sent | ||
| 538 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | ||
| 539 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | ||
| 540 | expect(nextRequest).toBeDefined(); | ||
| 541 | }); | ||
| 542 | expect(onResubscribe).toHaveBeenCalledOnce(); | ||
| 543 | |||
| 544 | const secondRequest = socket.sent | ||
| 545 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) | ||
| 546 | .find( | ||
| 547 | (message): message is { _tag: "Request"; id: string; tag: string } => | ||
| 548 | message._tag === "Request" && message.id !== firstRequest.id, | ||
| 549 | ); | ||
| 550 | if (!secondRequest) { | ||
| 551 | throw new Error("Expected a resubscribe request"); | ||
| 552 | } | ||
| 553 | expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); | ||
| 554 | expect(secondRequest.id).not.toBe(firstRequest.id); | ||
| 555 | |||
| 556 | const secondEvent = { | ||
| 557 | version: 1, | ||
| 558 | sequence: 2, | ||
| 559 | type: "welcome", | ||
| 560 | payload: { | ||
| 561 | environment: { | ||
| 562 | environmentId: "environment-local", | ||
| 563 | label: "Local environment", | ||
| 564 | platform: { os: "darwin", arch: "arm64" }, | ||
| 565 | serverVersion: "0.0.0-test", | ||
| 566 | capabilities: { repositoryIdentity: true }, | ||
| 567 | }, | ||
| 568 | cwd: "/tmp/two", | ||
| 569 | projectName: "two", | ||
| 570 | }, | ||
| 571 | }; | ||
| 572 | socket.serverMessage( | ||
| 573 | JSON.stringify({ | ||
| 574 | _tag: "Chunk", | ||
| 575 | requestId: secondRequest.id, | ||
| 576 | values: [secondEvent], | ||
| 577 | }), | ||
| 578 | ); | ||
| 579 | |||
| 580 | await waitFor(() => { | ||
| 581 | expect(listener).toHaveBeenLastCalledWith(secondEvent); | ||
| 582 | }); | ||
| 583 | |||
| 584 | unsubscribe(); | ||
| 585 | await transport.dispose(); | ||
| 586 | }); | ||
| 587 | |||
| 588 | it("re-subscribes live stream listeners after an explicit transport reconnect", async () => { | ||
| 589 | const transport = createTransport("ws://localhost:3020"); | ||
| 590 | const listener = vi.fn(); | ||
| 591 | const onResubscribe = vi.fn(); | ||
| 592 | |||
| 593 | const unsubscribe = transport.subscribe( | ||
| 594 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 595 | listener, | ||
| 596 | { onResubscribe }, | ||
| 597 | ); | ||
| 598 | |||
| 599 | await waitFor(() => { | ||
| 600 | expect(sockets).toHaveLength(1); | ||
| 601 | }); | ||
| 602 | |||
| 603 | const firstSocket = getSocket(); | ||
| 604 | firstSocket.open(); | ||
| 605 | |||
| 606 | await waitFor(() => { | ||
| 607 | expect(firstSocket.sent).toHaveLength(1); | ||
| 608 | }); | ||
| 609 | |||
| 610 | const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; | ||
| 611 | const firstEvent = { | ||
| 612 | version: 1, | ||
| 613 | sequence: 1, | ||
| 614 | type: "welcome", | ||
| 615 | payload: { | ||
| 616 | environment: { | ||
| 617 | environmentId: "environment-local", | ||
| 618 | label: "Local environment", | ||
| 619 | platform: { os: "darwin", arch: "arm64" }, | ||
| 620 | serverVersion: "0.0.0-test", | ||
| 621 | capabilities: { repositoryIdentity: true }, | ||
| 622 | }, | ||
| 623 | cwd: "/tmp/one", | ||
| 624 | projectName: "one", | ||
| 625 | }, | ||
| 626 | }; | ||
| 627 | |||
| 628 | firstSocket.serverMessage( | ||
| 629 | JSON.stringify({ | ||
| 630 | _tag: "Chunk", | ||
| 631 | requestId: firstRequest.id, | ||
| 632 | values: [firstEvent], | ||
| 633 | }), | ||
| 634 | ); | ||
| 635 | |||
| 636 | await waitFor(() => { | ||
| 637 | expect(listener).toHaveBeenLastCalledWith(firstEvent); | ||
| 638 | }); | ||
| 639 | |||
| 640 | await transport.reconnect(); | ||
| 641 | |||
| 642 | await waitFor(() => { | ||
| 643 | expect(sockets).toHaveLength(2); | ||
| 644 | }); | ||
| 645 | |||
| 646 | const secondSocket = getSocket(); | ||
| 647 | expect(secondSocket).not.toBe(firstSocket); | ||
| 648 | expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); | ||
| 649 | |||
| 650 | secondSocket.open(); | ||
| 651 | |||
| 652 | await waitFor(() => { | ||
| 653 | expect(secondSocket.sent).toHaveLength(1); | ||
| 654 | }); | ||
| 655 | |||
| 656 | const secondRequest = JSON.parse(secondSocket.sent[0] ?? "{}") as { | ||
| 657 | id: string; | ||
| 658 | tag: string; | ||
| 659 | }; | ||
| 660 | expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); | ||
| 661 | expect(secondRequest.id).not.toBe(firstRequest.id); | ||
| 662 | expect(onResubscribe).toHaveBeenCalledOnce(); | ||
| 663 | |||
| 664 | const secondEvent = { | ||
| 665 | version: 1, | ||
| 666 | sequence: 2, | ||
| 667 | type: "welcome", | ||
| 668 | payload: { | ||
| 669 | environment: { | ||
| 670 | environmentId: "environment-local", | ||
| 671 | label: "Local environment", | ||
| 672 | platform: { os: "darwin", arch: "arm64" }, | ||
| 673 | serverVersion: "0.0.0-test", | ||
| 674 | capabilities: { repositoryIdentity: true }, | ||
| 675 | }, | ||
| 676 | cwd: "/tmp/two", | ||
| 677 | projectName: "two", | ||
| 678 | }, | ||
| 679 | }; | ||
| 680 | |||
| 681 | secondSocket.serverMessage( | ||
| 682 | JSON.stringify({ | ||
| 683 | _tag: "Chunk", | ||
| 684 | requestId: secondRequest.id, | ||
| 685 | values: [secondEvent], | ||
| 686 | }), | ||
| 687 | ); | ||
| 688 | |||
| 689 | await waitFor(() => { | ||
| 690 | expect(listener).toHaveBeenLastCalledWith(secondEvent); | ||
| 691 | }); | ||
| 692 | |||
| 693 | unsubscribe(); | ||
| 694 | await transport.dispose(); | ||
| 695 | }); | ||
| 696 | |||
| 697 | it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { | ||
| 698 | const transport = createTransport("ws://localhost:3020"); | ||
| 699 | const listener = vi.fn(); | ||
| 700 | const onResubscribe = vi.fn(); | ||
| 701 | |||
| 702 | const unsubscribe = transport.subscribe( | ||
| 703 | (client) => client[WS_METHODS.subscribeServerLifecycle]({}), | ||
| 704 | listener, | ||
| 705 | { onResubscribe }, | ||
| 706 | ); | ||
| 707 | await waitFor(() => { | ||
| 708 | expect(sockets).toHaveLength(1); | ||
| 709 | }); | ||
| 710 | |||
| 711 | const socket = getSocket(); | ||
| 712 | socket.open(); | ||
| 713 | |||
| 714 | await waitFor(() => { | ||
| 715 | expect(socket.sent).toHaveLength(1); | ||
| 716 | }); | ||
| 717 | |||
| 718 | const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 719 | socket.serverMessage( | ||
| 720 | JSON.stringify({ | ||
| 721 | _tag: "Exit", | ||
| 722 | requestId: firstRequest.id, | ||
| 723 | exit: { | ||
| 724 | _tag: "Success", | ||
| 725 | value: null, | ||
| 726 | }, | ||
| 727 | }), | ||
| 728 | ); | ||
| 729 | |||
| 730 | await waitFor(() => { | ||
| 731 | const nextRequest = socket.sent | ||
| 732 | .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) | ||
| 733 | .find((message) => message._tag === "Request" && message.id !== firstRequest.id); | ||
| 734 | expect(nextRequest).toBeDefined(); | ||
| 735 | }); | ||
| 736 | expect(onResubscribe).not.toHaveBeenCalled(); | ||
| 737 | expect(listener).not.toHaveBeenCalled(); | ||
| 738 | |||
| 739 | unsubscribe(); | ||
| 740 | await transport.dispose(); | ||
| 741 | }); | ||
| 742 | |||
| 743 | it("does not retry stream subscriptions after application-level failures", async () => { | ||
| 744 | const warnSpy = vi.fn(); | ||
| 745 | const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); | ||
| 746 | let attempts = 0; | ||
| 747 | |||
| 748 | const unsubscribe = transport.subscribe( | ||
| 749 | () => | ||
| 750 | Stream.suspend(() => { | ||
| 751 | attempts += 1; | ||
| 752 | return Stream.fail(new Error("Git command failed in GitCore.statusDetails")); | ||
| 753 | }), | ||
| 754 | vi.fn(), | ||
| 755 | { retryDelay: 10 }, | ||
| 756 | ); | ||
| 757 | |||
| 758 | await waitFor(() => { | ||
| 759 | expect(sockets).toHaveLength(1); | ||
| 760 | }); | ||
| 761 | |||
| 762 | getSocket().open(); | ||
| 763 | |||
| 764 | await waitFor(() => { | ||
| 765 | expect(attempts).toBe(1); | ||
| 766 | }); | ||
| 767 | await Effect.runPromise(Effect.sleep(Duration.millis(50))); | ||
| 768 | |||
| 769 | expect(attempts).toBe(1); | ||
| 770 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription failed", { | ||
| 771 | error: "Git command failed in GitCore.statusDetails", | ||
| 772 | }); | ||
| 773 | expect(warnSpy).not.toHaveBeenCalledWith( | ||
| 774 | "WebSocket RPC subscription disconnected", | ||
| 775 | expect.anything(), | ||
| 776 | ); | ||
| 777 | |||
| 778 | unsubscribe(); | ||
| 779 | await transport.dispose(); | ||
| 780 | }); | ||
| 781 | |||
| 782 | it("keeps retrying stream subscriptions after transport failures", async () => { | ||
| 783 | const warnSpy = vi.fn(); | ||
| 784 | const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); | ||
| 785 | let attempts = 0; | ||
| 786 | |||
| 787 | const unsubscribe = transport.subscribe( | ||
| 788 | () => | ||
| 789 | Stream.suspend(() => { | ||
| 790 | attempts += 1; | ||
| 791 | return Stream.fail(new Error("SocketCloseError: WebSocket closed")); | ||
| 792 | }), | ||
| 793 | vi.fn(), | ||
| 794 | { retryDelay: 10 }, | ||
| 795 | ); | ||
| 796 | |||
| 797 | await waitFor(() => { | ||
| 798 | expect(sockets).toHaveLength(1); | ||
| 799 | }); | ||
| 800 | |||
| 801 | getSocket().open(); | ||
| 802 | |||
| 803 | await waitFor(() => { | ||
| 804 | expect(attempts).toBeGreaterThanOrEqual(2); | ||
| 805 | }); | ||
| 806 | |||
| 807 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { | ||
| 808 | error: "SocketCloseError: WebSocket closed", | ||
| 809 | }); | ||
| 810 | |||
| 811 | unsubscribe(); | ||
| 812 | await transport.dispose(); | ||
| 813 | }); | ||
| 814 | |||
| 815 | it("logs a transport disconnect once even when multiple subscriptions fail together", async () => { | ||
| 816 | const warnSpy = vi.fn(); | ||
| 817 | const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); | ||
| 818 | |||
| 819 | const unsubscribeA = transport.subscribe( | ||
| 820 | () => Stream.fail(new Error("SocketCloseError: 1006")), | ||
| 821 | vi.fn(), | ||
| 822 | { retryDelay: 10 }, | ||
| 823 | ); | ||
| 824 | const unsubscribeB = transport.subscribe( | ||
| 825 | () => Stream.fail(new Error("SocketCloseError: 1006")), | ||
| 826 | vi.fn(), | ||
| 827 | { retryDelay: 10 }, | ||
| 828 | ); | ||
| 829 | |||
| 830 | await waitFor(() => { | ||
| 831 | expect(sockets).toHaveLength(1); | ||
| 832 | }); | ||
| 833 | |||
| 834 | getSocket().open(); | ||
| 835 | |||
| 836 | await waitFor(() => { | ||
| 837 | expect(warnSpy).toHaveBeenCalledTimes(1); | ||
| 838 | }); | ||
| 839 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { | ||
| 840 | error: "SocketCloseError: 1006", | ||
| 841 | }); | ||
| 842 | |||
| 843 | unsubscribeA(); | ||
| 844 | unsubscribeB(); | ||
| 845 | await transport.dispose(); | ||
| 846 | }); | ||
| 847 | |||
| 848 | it("streams finite request events without re-subscribing", async () => { | ||
| 849 | const transport = createTransport("ws://localhost:3020"); | ||
| 850 | const listener = vi.fn(); | ||
| 851 | |||
| 852 | await waitFor(() => { | ||
| 853 | expect(sockets).toHaveLength(1); | ||
| 854 | }); | ||
| 855 | const socket = getSocket(); | ||
| 856 | socket.open(); | ||
| 857 | |||
| 858 | const requestPromise = transport.requestStream( | ||
| 859 | (client) => | ||
| 860 | client[WS_METHODS.gitRunStackedAction]({ | ||
| 861 | actionId: "action-1", | ||
| 862 | cwd: "/repo", | ||
| 863 | action: "commit", | ||
| 864 | }), | ||
| 865 | listener, | ||
| 866 | ); | ||
| 867 | |||
| 868 | await waitFor(() => { | ||
| 869 | expect(socket.sent).toHaveLength(1); | ||
| 870 | }); | ||
| 871 | |||
| 872 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 873 | const progressEvent = { | ||
| 874 | actionId: "action-1", | ||
| 875 | cwd: "/repo", | ||
| 876 | action: "commit", | ||
| 877 | kind: "phase_started", | ||
| 878 | phase: "commit", | ||
| 879 | label: "Committing...", | ||
| 880 | } as const; | ||
| 881 | |||
| 882 | socket.serverMessage( | ||
| 883 | JSON.stringify({ | ||
| 884 | _tag: "Chunk", | ||
| 885 | requestId: requestMessage.id, | ||
| 886 | values: [progressEvent], | ||
| 887 | }), | ||
| 888 | ); | ||
| 889 | socket.serverMessage( | ||
| 890 | JSON.stringify({ | ||
| 891 | _tag: "Exit", | ||
| 892 | requestId: requestMessage.id, | ||
| 893 | exit: { | ||
| 894 | _tag: "Success", | ||
| 895 | value: null, | ||
| 896 | }, | ||
| 897 | }), | ||
| 898 | ); | ||
| 899 | |||
| 900 | await expect(requestPromise).resolves.toBeUndefined(); | ||
| 901 | expect(listener).toHaveBeenCalledWith(progressEvent); | ||
| 902 | expect( | ||
| 903 | socket.sent.filter((message) => { | ||
| 904 | const parsed = JSON.parse(message) as { _tag?: string; tag?: string }; | ||
| 905 | return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction; | ||
| 906 | }), | ||
| 907 | ).toHaveLength(1); | ||
| 908 | await transport.dispose(); | ||
| 909 | }); | ||
| 910 | |||
| 911 | it("closes the client scope on the transport runtime before disposing the runtime", async () => { | ||
| 912 | const callOrder: string[] = []; | ||
| 913 | let resolveClose!: () => void; | ||
| 914 | const closePromise = new Promise<void>((resolve) => { | ||
| 915 | resolveClose = resolve; | ||
| 916 | }); | ||
| 917 | |||
| 918 | const runtime = { | ||
| 919 | runPromise: vi.fn(async () => { | ||
| 920 | callOrder.push("close:start"); | ||
| 921 | await closePromise; | ||
| 922 | callOrder.push("close:done"); | ||
| 923 | return undefined; | ||
| 924 | }), | ||
| 925 | dispose: vi.fn(async () => { | ||
| 926 | callOrder.push("runtime:dispose"); | ||
| 927 | }), | ||
| 928 | }; | ||
| 929 | const transport = { | ||
| 930 | disposed: false, | ||
| 931 | session: { | ||
| 932 | clientScope: {} as never, | ||
| 933 | runtime, | ||
| 934 | }, | ||
| 935 | closeSession: ( | ||
| 936 | WsTransport.prototype as unknown as { | ||
| 937 | closeSession: (session: { | ||
| 938 | clientScope: unknown; | ||
| 939 | runtime: { dispose: () => Promise<void>; runPromise: () => Promise<void> }; | ||
| 940 | }) => Promise<void>; | ||
| 941 | } | ||
| 942 | ).closeSession, | ||
| 943 | } as unknown as WsTransport; | ||
| 944 | |||
| 945 | void WsTransport.prototype.dispose.call(transport); | ||
| 946 | |||
| 947 | expect(runtime.runPromise).toHaveBeenCalledTimes(1); | ||
| 948 | expect(runtime.dispose).not.toHaveBeenCalled(); | ||
| 949 | expect((transport as unknown as { disposed: boolean }).disposed).toBe(true); | ||
| 950 | |||
| 951 | resolveClose(); | ||
| 952 | |||
| 953 | await waitFor(() => { | ||
| 954 | expect(runtime.dispose).toHaveBeenCalledTimes(1); | ||
| 955 | }); | ||
| 956 | |||
| 957 | expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); | ||
| 958 | }); | ||
| 959 | }); | ||
packages/client-runtime/src/wsTransport.ts created+370| ... | @@ -0,0 +1,370 @@ | ||
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as ManagedRuntime from "effect/ManagedRuntime"; | ||
| 7 | import * as Scope from "effect/Scope"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import { RpcClient } from "effect/unstable/rpc"; | ||
| 10 | |||
| 11 | import { isTransportConnectionErrorMessage } from "./transportError.ts"; | ||
| 12 | import { | ||
| 13 | createWsRpcProtocolLayer, | ||
| 14 | makeWsRpcProtocolClient, | ||
| 15 | type WsProtocolLifecycleHandlers, | ||
| 16 | type WsRpcProtocolClient, | ||
| 17 | type WsRpcProtocolSocketUrlProvider, | ||
| 18 | } from "./wsRpcProtocol.ts"; | ||
| 19 | |||
| 20 | export interface WsTransportOptions { | ||
| 21 | /** | ||
| 22 | * Merged into the transport `ManagedRuntime` alongside the RPC protocol layer | ||
| 23 | * (for example a `Tracer` layer for OTLP). | ||
| 24 | */ | ||
| 25 | readonly tracingLayer?: Layer.Layer<never, never, never>; | ||
| 26 | /** | ||
| 27 | * Override protocol construction (defaults to {@link createWsRpcProtocolLayer}). | ||
| 28 | * The web app supplies its instrumented layer factory. | ||
| 29 | */ | ||
| 30 | readonly createProtocolLayer?: ( | ||
| 31 | url: WsRpcProtocolSocketUrlProvider, | ||
| 32 | lifecycleHandlers?: WsProtocolLifecycleHandlers, | ||
| 33 | ) => Layer.Layer<RpcClient.Protocol, never, never>; | ||
| 34 | readonly logWarning?: (message: string, metadata: { readonly error: string }) => void; | ||
| 35 | /** | ||
| 36 | * Invoked at the start of {@link WsTransport.reconnect} before the session is replaced. | ||
| 37 | */ | ||
| 38 | readonly onBeforeReconnect?: () => void; | ||
| 39 | } | ||
| 40 | |||
| 41 | interface SubscribeOptions { | ||
| 42 | readonly retryDelay?: Duration.Input; | ||
| 43 | readonly onResubscribe?: () => void; | ||
| 44 | readonly tag?: string; | ||
| 45 | } | ||
| 46 | |||
| 47 | const DEFAULT_SUBSCRIPTION_RETRY_DELAY = Duration.millis(250); | ||
| 48 | const NOOP: () => void = () => undefined; | ||
| 49 | |||
| 50 | interface TransportSession { | ||
| 51 | readonly clientPromise: Promise<WsRpcProtocolClient>; | ||
| 52 | readonly clientScope: Scope.Closeable; | ||
| 53 | readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>; | ||
| 54 | } | ||
| 55 | |||
| 56 | function formatErrorMessage(error: unknown): string { | ||
| 57 | if (error instanceof Error && error.message.trim().length > 0) { | ||
| 58 | return error.message; | ||
| 59 | } | ||
| 60 | |||
| 61 | return String(error); | ||
| 62 | } | ||
| 63 | |||
| 64 | export class WsTransport { | ||
| 65 | private readonly url: WsRpcProtocolSocketUrlProvider; | ||
| 66 | private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; | ||
| 67 | private readonly options: WsTransportOptions | undefined; | ||
| 68 | private disposed = false; | ||
| 69 | private hasReportedTransportDisconnect = false; | ||
| 70 | private intentionalCloseDepth = 0; | ||
| 71 | private nextSessionId = 0; | ||
| 72 | private activeSessionId = 0; | ||
| 73 | private lastHeartbeatPongAt: number | null = null; | ||
| 74 | private readonly streamRequestStartListeners = new Set< | ||
| 75 | (info: { readonly tag: string }) => void | ||
| 76 | >(); | ||
| 77 | private reconnectChain: Promise<void> = Promise.resolve(); | ||
| 78 | private session: TransportSession; | ||
| 79 | |||
| 80 | constructor( | ||
| 81 | url: WsRpcProtocolSocketUrlProvider, | ||
| 82 | lifecycleHandlers?: WsProtocolLifecycleHandlers, | ||
| 83 | options?: WsTransportOptions, | ||
| 84 | ) { | ||
| 85 | this.url = url; | ||
| 86 | this.lifecycleHandlers = lifecycleHandlers; | ||
| 87 | this.options = options; | ||
| 88 | this.session = this.createSession(); | ||
| 89 | } | ||
| 90 | |||
| 91 | async request<TSuccess>( | ||
| 92 | execute: (client: WsRpcProtocolClient) => Effect.Effect<TSuccess, Error, never>, | ||
| 93 | ): Promise<TSuccess> { | ||
| 94 | if (this.disposed) { | ||
| 95 | throw new Error("Transport disposed"); | ||
| 96 | } | ||
| 97 | |||
| 98 | const session = this.session; | ||
| 99 | const client = await session.clientPromise; | ||
| 100 | return await session.runtime.runPromise(Effect.suspend(() => execute(client))); | ||
| 101 | } | ||
| 102 | |||
| 103 | async requestStream<TValue>( | ||
| 104 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | ||
| 105 | listener: (value: TValue) => void, | ||
| 106 | ): Promise<void> { | ||
| 107 | if (this.disposed) { | ||
| 108 | throw new Error("Transport disposed"); | ||
| 109 | } | ||
| 110 | |||
| 111 | const session = this.session; | ||
| 112 | const client = await session.clientPromise; | ||
| 113 | await session.runtime.runPromise( | ||
| 114 | Stream.runForEach(connect(client), (value) => | ||
| 115 | Effect.sync(() => { | ||
| 116 | try { | ||
| 117 | listener(value); | ||
| 118 | } catch { | ||
| 119 | // Ignore listener errors so the stream can finish cleanly. | ||
| 120 | } | ||
| 121 | }), | ||
| 122 | ), | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | |||
| 126 | subscribe<TValue>( | ||
| 127 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | ||
| 128 | listener: (value: TValue) => void, | ||
| 129 | options?: SubscribeOptions, | ||
| 130 | ): () => void { | ||
| 131 | if (this.disposed) { | ||
| 132 | return NOOP; | ||
| 133 | } | ||
| 134 | |||
| 135 | let active = true; | ||
| 136 | let hasReceivedValue = false; | ||
| 137 | const retryDelayMs = Duration.toMillis( | ||
| 138 | Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY), | ||
| 139 | ); | ||
| 140 | let cancelCurrentStream: () => void = NOOP; | ||
| 141 | const onStreamRequestStart = (info: { readonly tag: string }) => { | ||
| 142 | if ( | ||
| 143 | !hasReceivedValue || | ||
| 144 | !active || | ||
| 145 | (options?.tag !== undefined && info.tag !== options.tag) | ||
| 146 | ) { | ||
| 147 | return; | ||
| 148 | } | ||
| 149 | |||
| 150 | try { | ||
| 151 | options?.onResubscribe?.(); | ||
| 152 | } catch { | ||
| 153 | // Ignore reconnect hook failures so the stream can recover. | ||
| 154 | } | ||
| 155 | }; | ||
| 156 | this.streamRequestStartListeners.add(onStreamRequestStart); | ||
| 157 | |||
| 158 | void (async () => { | ||
| 159 | for (;;) { | ||
| 160 | if (!active || this.disposed) { | ||
| 161 | return; | ||
| 162 | } | ||
| 163 | |||
| 164 | const session = this.session; | ||
| 165 | try { | ||
| 166 | const runningStream = this.runStreamOnSession( | ||
| 167 | session, | ||
| 168 | connect, | ||
| 169 | listener, | ||
| 170 | () => active, | ||
| 171 | () => { | ||
| 172 | this.hasReportedTransportDisconnect = false; | ||
| 173 | hasReceivedValue = true; | ||
| 174 | }, | ||
| 175 | ); | ||
| 176 | cancelCurrentStream = runningStream.cancel; | ||
| 177 | await runningStream.completed; | ||
| 178 | cancelCurrentStream = NOOP; | ||
| 179 | } catch (error) { | ||
| 180 | cancelCurrentStream = NOOP; | ||
| 181 | if (!active || this.disposed) { | ||
| 182 | return; | ||
| 183 | } | ||
| 184 | |||
| 185 | // Skip retry if the session has already been replaced by a reconnect. | ||
| 186 | if (session !== this.session) { | ||
| 187 | continue; | ||
| 188 | } | ||
| 189 | |||
| 190 | const formattedError = formatErrorMessage(error); | ||
| 191 | if (!isTransportConnectionErrorMessage(formattedError)) { | ||
| 192 | this.logWarning("WebSocket RPC subscription failed", { error: formattedError }); | ||
| 193 | return; | ||
| 194 | } | ||
| 195 | |||
| 196 | if (!this.hasReportedTransportDisconnect) { | ||
| 197 | this.logWarning("WebSocket RPC subscription disconnected", { | ||
| 198 | error: formattedError, | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | this.hasReportedTransportDisconnect = true; | ||
| 202 | await sleep(retryDelayMs); | ||
| 203 | } | ||
| 204 | } | ||
| 205 | })(); | ||
| 206 | |||
| 207 | return () => { | ||
| 208 | active = false; | ||
| 209 | this.streamRequestStartListeners.delete(onStreamRequestStart); | ||
| 210 | cancelCurrentStream(); | ||
| 211 | }; | ||
| 212 | } | ||
| 213 | |||
| 214 | async reconnect() { | ||
| 215 | if (this.disposed) { | ||
| 216 | throw new Error("Transport disposed"); | ||
| 217 | } | ||
| 218 | |||
| 219 | const reconnectOperation = this.reconnectChain.then(async () => { | ||
| 220 | if (this.disposed) { | ||
| 221 | throw new Error("Transport disposed"); | ||
| 222 | } | ||
| 223 | |||
| 224 | try { | ||
| 225 | this.options?.onBeforeReconnect?.(); | ||
| 226 | } catch { | ||
| 227 | // Ignore hook failures so reconnect can proceed. | ||
| 228 | } | ||
| 229 | |||
| 230 | this.lastHeartbeatPongAt = null; | ||
| 231 | const previousSession = this.session; | ||
| 232 | this.session = this.createSession(); | ||
| 233 | await this.closeSession(previousSession); | ||
| 234 | }); | ||
| 235 | |||
| 236 | this.reconnectChain = reconnectOperation.catch(() => undefined); | ||
| 237 | await reconnectOperation; | ||
| 238 | } | ||
| 239 | |||
| 240 | isHeartbeatFresh(maxAgeMs = 15_000): boolean { | ||
| 241 | return ( | ||
| 242 | this.lastHeartbeatPongAt !== null && performance.now() - this.lastHeartbeatPongAt <= maxAgeMs | ||
| 243 | ); | ||
| 244 | } | ||
| 245 | |||
| 246 | async dispose() { | ||
| 247 | if (this.disposed) { | ||
| 248 | return; | ||
| 249 | } | ||
| 250 | |||
| 251 | this.disposed = true; | ||
| 252 | await this.closeSession(this.session); | ||
| 253 | } | ||
| 254 | |||
| 255 | private closeSession(session: TransportSession) { | ||
| 256 | this.intentionalCloseDepth += 1; | ||
| 257 | return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { | ||
| 258 | this.intentionalCloseDepth = Math.max(0, this.intentionalCloseDepth - 1); | ||
| 259 | session.runtime.dispose(); | ||
| 260 | }); | ||
| 261 | } | ||
| 262 | |||
| 263 | private createSession(): TransportSession { | ||
| 264 | const protocolFactory = this.options?.createProtocolLayer ?? createWsRpcProtocolLayer; | ||
| 265 | const sessionId = this.nextSessionId + 1; | ||
| 266 | this.nextSessionId = sessionId; | ||
| 267 | this.activeSessionId = sessionId; | ||
| 268 | const lifecycleHandlers = this.lifecycleHandlers; | ||
| 269 | const protocolLayer = protocolFactory(this.url, { | ||
| 270 | ...lifecycleHandlers, | ||
| 271 | isActive: () => | ||
| 272 | !this.disposed && | ||
| 273 | this.activeSessionId === sessionId && | ||
| 274 | (lifecycleHandlers?.isActive?.() ?? true), | ||
| 275 | isCloseIntentional: () => | ||
| 276 | this.disposed || | ||
| 277 | this.intentionalCloseDepth > 0 || | ||
| 278 | lifecycleHandlers?.isCloseIntentional?.() === true, | ||
| 279 | onHeartbeatPong: () => { | ||
| 280 | this.lastHeartbeatPongAt = performance.now(); | ||
| 281 | lifecycleHandlers?.onHeartbeatPong?.(); | ||
| 282 | }, | ||
| 283 | onRequestStart: (info) => { | ||
| 284 | lifecycleHandlers?.onRequestStart?.(info); | ||
| 285 | if (!info.stream) { | ||
| 286 | return; | ||
| 287 | } | ||
| 288 | for (const listener of this.streamRequestStartListeners) { | ||
| 289 | listener({ tag: info.tag }); | ||
| 290 | } | ||
| 291 | }, | ||
| 292 | }); | ||
| 293 | const rootLayer = this.options?.tracingLayer | ||
| 294 | ? Layer.mergeAll(protocolLayer, this.options.tracingLayer) | ||
| 295 | : protocolLayer; | ||
| 296 | const runtime = ManagedRuntime.make(rootLayer); | ||
| 297 | const clientScope = runtime.runSync(Scope.make()); | ||
| 298 | return { | ||
| 299 | runtime, | ||
| 300 | clientScope, | ||
| 301 | clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)), | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | private logWarning(message: string, metadata: { readonly error: string }) { | ||
| 306 | const logWarning = this.options?.logWarning; | ||
| 307 | if (logWarning) { | ||
| 308 | logWarning(message, metadata); | ||
| 309 | } else { | ||
| 310 | Effect.runSync(Effect.logWarning(message, metadata)); | ||
| 311 | } | ||
| 312 | } | ||
| 313 | |||
| 314 | private runStreamOnSession<TValue>( | ||
| 315 | session: TransportSession, | ||
| 316 | connect: (client: WsRpcProtocolClient) => Stream.Stream<TValue, Error, never>, | ||
| 317 | listener: (value: TValue) => void, | ||
| 318 | isActive: () => boolean, | ||
| 319 | markValueReceived: () => void, | ||
| 320 | ): { | ||
| 321 | readonly cancel: () => void; | ||
| 322 | readonly completed: Promise<void>; | ||
| 323 | } { | ||
| 324 | let resolveCompleted!: () => void; | ||
| 325 | let rejectCompleted!: (error: unknown) => void; | ||
| 326 | const completed = new Promise<void>((resolve, reject) => { | ||
| 327 | resolveCompleted = resolve; | ||
| 328 | rejectCompleted = reject; | ||
| 329 | }); | ||
| 330 | const cancel = session.runtime.runCallback( | ||
| 331 | Effect.promise(() => session.clientPromise).pipe( | ||
| 332 | Effect.flatMap((client) => | ||
| 333 | Stream.runForEach(connect(client), (value) => | ||
| 334 | Effect.sync(() => { | ||
| 335 | if (!isActive()) { | ||
| 336 | return; | ||
| 337 | } | ||
| 338 | |||
| 339 | markValueReceived(); | ||
| 340 | try { | ||
| 341 | listener(value); | ||
| 342 | } catch { | ||
| 343 | // Ignore listener errors so the stream stays live. | ||
| 344 | } | ||
| 345 | }), | ||
| 346 | ), | ||
| 347 | ), | ||
| 348 | ), | ||
| 349 | { | ||
| 350 | onExit: (exit) => { | ||
| 351 | if (Exit.isSuccess(exit)) { | ||
| 352 | resolveCompleted(); | ||
| 353 | return; | ||
| 354 | } | ||
| 355 | |||
| 356 | rejectCompleted(Cause.squash(exit.cause)); | ||
| 357 | }, | ||
| 358 | }, | ||
| 359 | ); | ||
| 360 | |||
| 361 | return { | ||
| 362 | cancel, | ||
| 363 | completed, | ||
| 364 | }; | ||
| 365 | } | ||
| 366 | } | ||
| 367 | |||
| 368 | function sleep(ms: number): Promise<void> { | ||
| 369 | return Effect.runPromise(Effect.sleep(Duration.millis(ms))); | ||
| 370 | } | ||
packages/client-runtime/vitest.config.ts created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | import { defineConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | export default defineConfig({ | ||
| 4 | test: { | ||
| 5 | include: ["src/**/*.test.ts"], | ||
| 6 | environment: "node", | ||
| 7 | }, | ||
| 8 | }); | ||
packages/contracts/src/index.ts+1| ... | @@ -19,4 +19,5 @@ export * from "./orchestration.ts"; | ... | @@ -19,4 +19,5 @@ export * from "./orchestration.ts"; |
| 19 | export * from "./editor.ts"; | 19 | export * from "./editor.ts"; |
| 20 | export * from "./project.ts"; | 20 | export * from "./project.ts"; |
| 21 | export * from "./filesystem.ts"; | 21 | export * from "./filesystem.ts"; |
| 22 | export * from "./review.ts"; | ||
| 22 | export * from "./rpc.ts"; | 23 | export * from "./rpc.ts"; |
packages/contracts/src/ipc.ts+26-8| ... | @@ -1,10 +1,6 @@ | ... | @@ -1,10 +1,6 @@ |
| 1 | import type { | 1 | import type { |
| 2 | VcsSwitchRefInput, | ||
| 3 | VcsSwitchRefResult, | ||
| 4 | VcsCreateRefInput, | 2 | VcsCreateRefInput, |
| 5 | GitPreparePullRequestThreadInput, | 3 | VcsCreateRefResult, |
| 6 | GitPreparePullRequestThreadResult, | ||
| 7 | GitPullRequestRefInput, | ||
| 8 | VcsCreateWorktreeInput, | 4 | VcsCreateWorktreeInput, |
| 9 | VcsCreateWorktreeResult, | 5 | VcsCreateWorktreeResult, |
| 10 | VcsInitInput, | 6 | VcsInitInput, |
| ... | @@ -13,11 +9,16 @@ import type { | ... | @@ -13,11 +9,16 @@ import type { |
| 13 | VcsPullInput, | 9 | VcsPullInput, |
| 14 | VcsPullResult, | 10 | VcsPullResult, |
| 15 | VcsRemoveWorktreeInput, | 11 | VcsRemoveWorktreeInput, |
| 12 | VcsSwitchRefInput, | ||
| 13 | VcsSwitchRefResult, | ||
| 14 | GitPreparePullRequestThreadInput, | ||
| 15 | GitPreparePullRequestThreadResult, | ||
| 16 | GitPullRequestRefInput, | ||
| 16 | GitResolvePullRequestResult, | 17 | GitResolvePullRequestResult, |
| 17 | VcsStatusInput, | 18 | VcsStatusInput, |
| 18 | VcsStatusResult, | 19 | VcsStatusResult, |
| 19 | VcsCreateRefResult, | ||
| 20 | } from "./git.ts"; | 20 | } from "./git.ts"; |
| 21 | import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; | ||
| 21 | import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; | 22 | import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; |
| 22 | import type { | 23 | import type { |
| 23 | ProjectSearchEntriesInput, | 24 | ProjectSearchEntriesInput, |
| ... | @@ -40,9 +41,11 @@ import type { | ... | @@ -40,9 +41,11 @@ import type { |
| 40 | ServerUpsertKeybindingResult, | 41 | ServerUpsertKeybindingResult, |
| 41 | } from "./server.ts"; | 42 | } from "./server.ts"; |
| 42 | import type { | 43 | import type { |
| 44 | TerminalAttachInput, | ||
| 45 | TerminalAttachStreamEvent, | ||
| 43 | TerminalClearInput, | 46 | TerminalClearInput, |
| 44 | TerminalCloseInput, | 47 | TerminalCloseInput, |
| 45 | TerminalEvent, | 48 | TerminalMetadataStreamEvent, |
| 46 | TerminalOpenInput, | 49 | TerminalOpenInput, |
| 47 | TerminalResizeInput, | 50 | TerminalResizeInput, |
| 48 | TerminalRestartInput, | 51 | TerminalRestartInput, |
| ... | @@ -496,12 +499,24 @@ export interface LocalApi { | ... | @@ -496,12 +499,24 @@ export interface LocalApi { |
| 496 | export interface EnvironmentApi { | 499 | export interface EnvironmentApi { |
| 497 | terminal: { | 500 | terminal: { |
| 498 | open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>; | 501 | open: (input: typeof TerminalOpenInput.Encoded) => Promise<TerminalSessionSnapshot>; |
| 502 | attach: ( | ||
| 503 | input: typeof TerminalAttachInput.Encoded, | ||
| 504 | callback: (event: TerminalAttachStreamEvent) => void, | ||
| 505 | options?: { | ||
| 506 | onResubscribe?: () => void; | ||
| 507 | }, | ||
| 508 | ) => () => void; | ||
| 499 | write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>; | 509 | write: (input: typeof TerminalWriteInput.Encoded) => Promise<void>; |
| 500 | resize: (input: typeof TerminalResizeInput.Encoded) => Promise<void>; | 510 | resize: (input: typeof TerminalResizeInput.Encoded) => Promise<void>; |
| 501 | clear: (input: typeof TerminalClearInput.Encoded) => Promise<void>; | 511 | clear: (input: typeof TerminalClearInput.Encoded) => Promise<void>; |
| 502 | restart: (input: typeof TerminalRestartInput.Encoded) => Promise<TerminalSessionSnapshot>; | 512 | restart: (input: typeof TerminalRestartInput.Encoded) => Promise<TerminalSessionSnapshot>; |
| 503 | close: (input: typeof TerminalCloseInput.Encoded) => Promise<void>; | 513 | close: (input: typeof TerminalCloseInput.Encoded) => Promise<void>; |
| 504 | onEvent: (callback: (event: TerminalEvent) => void) => () => void; | 514 | onMetadata: ( |
| 515 | callback: (event: TerminalMetadataStreamEvent) => void, | ||
| 516 | options?: { | ||
| 517 | onResubscribe?: () => void; | ||
| 518 | }, | ||
| 519 | ) => () => void; | ||
| 505 | }; | 520 | }; |
| 506 | projects: { | 521 | projects: { |
| 507 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; | 522 | searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>; |
| ... | @@ -544,6 +559,9 @@ export interface EnvironmentApi { | ... | @@ -544,6 +559,9 @@ export interface EnvironmentApi { |
| 544 | input: GitPreparePullRequestThreadInput, | 559 | input: GitPreparePullRequestThreadInput, |
| 545 | ) => Promise<GitPreparePullRequestThreadResult>; | 560 | ) => Promise<GitPreparePullRequestThreadResult>; |
| 546 | }; | 561 | }; |
| 562 | review: { | ||
| 563 | getDiffPreview: (input: ReviewDiffPreviewInput) => Promise<ReviewDiffPreviewResult>; | ||
| 564 | }; | ||
| 547 | orchestration: { | 565 | orchestration: { |
| 548 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; | 566 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; |
| 549 | getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>; | 567 | getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>; |
packages/contracts/src/review.ts created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 3 | import { GitCommandError } from "./git.ts"; | ||
| 4 | import { VcsError } from "./vcs.ts"; | ||
| 5 | |||
| 6 | export const ReviewDiffPreviewInput = Schema.Struct({ | ||
| 7 | cwd: TrimmedNonEmptyString, | ||
| 8 | baseRef: Schema.optional(TrimmedNonEmptyString), | ||
| 9 | }); | ||
| 10 | export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type; | ||
| 11 | |||
| 12 | export const ReviewDiffPreviewSourceKind = Schema.Literals(["working-tree", "branch-range"]); | ||
| 13 | export type ReviewDiffPreviewSourceKind = typeof ReviewDiffPreviewSourceKind.Type; | ||
| 14 | |||
| 15 | export const ReviewDiffPreviewSource = Schema.Struct({ | ||
| 16 | id: TrimmedNonEmptyString, | ||
| 17 | kind: ReviewDiffPreviewSourceKind, | ||
| 18 | title: TrimmedNonEmptyString, | ||
| 19 | baseRef: Schema.NullOr(TrimmedNonEmptyString), | ||
| 20 | headRef: Schema.NullOr(TrimmedNonEmptyString), | ||
| 21 | diff: Schema.String, | ||
| 22 | diffHash: TrimmedNonEmptyString, | ||
| 23 | truncated: Schema.Boolean, | ||
| 24 | }); | ||
| 25 | export type ReviewDiffPreviewSource = typeof ReviewDiffPreviewSource.Type; | ||
| 26 | |||
| 27 | export const ReviewDiffPreviewResult = Schema.Struct({ | ||
| 28 | cwd: TrimmedNonEmptyString, | ||
| 29 | generatedAt: Schema.DateTimeUtc, | ||
| 30 | sources: Schema.Array(ReviewDiffPreviewSource), | ||
| 31 | }); | ||
| 32 | export type ReviewDiffPreviewResult = typeof ReviewDiffPreviewResult.Type; | ||
| 33 | |||
| 34 | export const ReviewDiffPreviewError = Schema.Union([VcsError, GitCommandError]); | ||
| 35 | export type ReviewDiffPreviewError = typeof ReviewDiffPreviewError.Type; | ||
packages/contracts/src/rpc.ts+40| ... | @@ -34,6 +34,11 @@ import { | ... | @@ -34,6 +34,11 @@ import { |
| 34 | VcsStatusResult, | 34 | VcsStatusResult, |
| 35 | VcsStatusStreamEvent, | 35 | VcsStatusStreamEvent, |
| 36 | } from "./git.ts"; | 36 | } from "./git.ts"; |
| 37 | import { | ||
| 38 | ReviewDiffPreviewError, | ||
| 39 | ReviewDiffPreviewInput, | ||
| 40 | ReviewDiffPreviewResult, | ||
| 41 | } from "./review.ts"; | ||
| 37 | import { KeybindingsConfigError } from "./keybindings.ts"; | 42 | import { KeybindingsConfigError } from "./keybindings.ts"; |
| 38 | import { | 43 | import { |
| 39 | ClientOrchestrationCommand, | 44 | ClientOrchestrationCommand, |
| ... | @@ -58,10 +63,13 @@ import { | ... | @@ -58,10 +63,13 @@ import { |
| 58 | ProjectWriteFileResult, | 63 | ProjectWriteFileResult, |
| 59 | } from "./project.ts"; | 64 | } from "./project.ts"; |
| 60 | import { | 65 | import { |
| 66 | TerminalAttachInput, | ||
| 67 | TerminalAttachStreamEvent, | ||
| 61 | TerminalClearInput, | 68 | TerminalClearInput, |
| 62 | TerminalCloseInput, | 69 | TerminalCloseInput, |
| 63 | TerminalError, | 70 | TerminalError, |
| 64 | TerminalEvent, | 71 | TerminalEvent, |
| 72 | TerminalMetadataStreamEvent, | ||
| 65 | TerminalOpenInput, | 73 | TerminalOpenInput, |
| 66 | TerminalResizeInput, | 74 | TerminalResizeInput, |
| 67 | TerminalRestartInput, | 75 | TerminalRestartInput, |
| ... | @@ -128,8 +136,12 @@ export const WS_METHODS = { | ... | @@ -128,8 +136,12 @@ export const WS_METHODS = { |
| 128 | gitResolvePullRequest: "git.resolvePullRequest", | 136 | gitResolvePullRequest: "git.resolvePullRequest", |
| 129 | gitPreparePullRequestThread: "git.preparePullRequestThread", | 137 | gitPreparePullRequestThread: "git.preparePullRequestThread", |
| 130 | 138 | ||
| 139 | // Review methods | ||
| 140 | reviewGetDiffPreview: "review.getDiffPreview", | ||
| 141 | |||
| 131 | // Terminal methods | 142 | // Terminal methods |
| 132 | terminalOpen: "terminal.open", | 143 | terminalOpen: "terminal.open", |
| 144 | terminalAttach: "terminal.attach", | ||
| 133 | terminalWrite: "terminal.write", | 145 | terminalWrite: "terminal.write", |
| 134 | terminalResize: "terminal.resize", | 146 | terminalResize: "terminal.resize", |
| 135 | terminalClear: "terminal.clear", | 147 | terminalClear: "terminal.clear", |
| ... | @@ -158,6 +170,7 @@ export const WS_METHODS = { | ... | @@ -158,6 +170,7 @@ export const WS_METHODS = { |
| 158 | // Streaming subscriptions | 170 | // Streaming subscriptions |
| 159 | subscribeVcsStatus: "subscribeVcsStatus", | 171 | subscribeVcsStatus: "subscribeVcsStatus", |
| 160 | subscribeTerminalEvents: "subscribeTerminalEvents", | 172 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 173 | subscribeTerminalMetadata: "subscribeTerminalMetadata", | ||
| 161 | subscribeServerConfig: "subscribeServerConfig", | 174 | subscribeServerConfig: "subscribeServerConfig", |
| 162 | subscribeServerLifecycle: "subscribeServerLifecycle", | 175 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| 163 | subscribeAuthAccess: "subscribeAuthAccess", | 176 | subscribeAuthAccess: "subscribeAuthAccess", |
| ... | @@ -359,12 +372,30 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { | ... | @@ -359,12 +372,30 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { |
| 359 | error: VcsError, | 372 | error: VcsError, |
| 360 | }); | 373 | }); |
| 361 | 374 | ||
| 375 | /** | ||
| 376 | * Ephemeral live diff preview for compact/mobile surfaces. | ||
| 377 | * Not the persisted T3 Review model. Future review sessions should use | ||
| 378 | * review.open* + review.getSnapshot. | ||
| 379 | */ | ||
| 380 | export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { | ||
| 381 | payload: ReviewDiffPreviewInput, | ||
| 382 | success: ReviewDiffPreviewResult, | ||
| 383 | error: ReviewDiffPreviewError, | ||
| 384 | }); | ||
| 385 | |||
| 362 | export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { | 386 | export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { |
| 363 | payload: TerminalOpenInput, | 387 | payload: TerminalOpenInput, |
| 364 | success: TerminalSessionSnapshot, | 388 | success: TerminalSessionSnapshot, |
| 365 | error: TerminalError, | 389 | error: TerminalError, |
| 366 | }); | 390 | }); |
| 367 | 391 | ||
| 392 | export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { | ||
| 393 | payload: TerminalAttachInput, | ||
| 394 | success: TerminalAttachStreamEvent, | ||
| 395 | error: TerminalError, | ||
| 396 | stream: true, | ||
| 397 | }); | ||
| 398 | |||
| 368 | export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { | 399 | export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { |
| 369 | payload: TerminalWriteInput, | 400 | payload: TerminalWriteInput, |
| 370 | error: TerminalError, | 401 | error: TerminalError, |
| ... | @@ -453,6 +484,12 @@ export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTermina | ... | @@ -453,6 +484,12 @@ export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTermina |
| 453 | stream: true, | 484 | stream: true, |
| 454 | }); | 485 | }); |
| 455 | 486 | ||
| 487 | export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { | ||
| 488 | payload: Schema.Struct({}), | ||
| 489 | success: TerminalMetadataStreamEvent, | ||
| 490 | stream: true, | ||
| 491 | }); | ||
| 492 | |||
| 456 | export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { | 493 | export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerConfig, { |
| 457 | payload: Schema.Struct({}), | 494 | payload: Schema.Struct({}), |
| 458 | success: ServerConfigStreamEvent, | 495 | success: ServerConfigStreamEvent, |
| ... | @@ -504,13 +541,16 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -504,13 +541,16 @@ export const WsRpcGroup = RpcGroup.make( |
| 504 | WsVcsCreateRefRpc, | 541 | WsVcsCreateRefRpc, |
| 505 | WsVcsSwitchRefRpc, | 542 | WsVcsSwitchRefRpc, |
| 506 | WsVcsInitRpc, | 543 | WsVcsInitRpc, |
| 544 | WsReviewGetDiffPreviewRpc, | ||
| 507 | WsTerminalOpenRpc, | 545 | WsTerminalOpenRpc, |
| 546 | WsTerminalAttachRpc, | ||
| 508 | WsTerminalWriteRpc, | 547 | WsTerminalWriteRpc, |
| 509 | WsTerminalResizeRpc, | 548 | WsTerminalResizeRpc, |
| 510 | WsTerminalClearRpc, | 549 | WsTerminalClearRpc, |
| 511 | WsTerminalRestartRpc, | 550 | WsTerminalRestartRpc, |
| 512 | WsTerminalCloseRpc, | 551 | WsTerminalCloseRpc, |
| 513 | WsSubscribeTerminalEventsRpc, | 552 | WsSubscribeTerminalEventsRpc, |
| 553 | WsSubscribeTerminalMetadataRpc, | ||
| 514 | WsSubscribeServerConfigRpc, | 554 | WsSubscribeServerConfigRpc, |
| 515 | WsSubscribeServerLifecycleRpc, | 555 | WsSubscribeServerLifecycleRpc, |
| 516 | WsSubscribeAuthAccessRpc, | 556 | WsSubscribeAuthAccessRpc, |
packages/contracts/src/terminal.test.ts+68-13| ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; | ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | DEFAULT_TERMINAL_ID, | 5 | DEFAULT_TERMINAL_ID, |
| 6 | TerminalAttachInput, | ||
| 6 | TerminalClearInput, | 7 | TerminalClearInput, |
| 7 | TerminalCloseInput, | 8 | TerminalCloseInput, |
| 8 | TerminalEvent, | 9 | TerminalEvent, |
| ... | @@ -31,6 +32,7 @@ describe("TerminalOpenInput", () => { | ... | @@ -31,6 +32,7 @@ describe("TerminalOpenInput", () => { |
| 31 | expect( | 32 | expect( |
| 32 | decodes(TerminalOpenInput, { | 33 | decodes(TerminalOpenInput, { |
| 33 | threadId: "thread-1", | 34 | threadId: "thread-1", |
| 35 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 34 | cwd: "/tmp/project", | 36 | cwd: "/tmp/project", |
| 35 | cols: 120, | 37 | cols: 120, |
| 36 | rows: 40, | 38 | rows: 40, |
| ... | @@ -42,6 +44,7 @@ describe("TerminalOpenInput", () => { | ... | @@ -42,6 +44,7 @@ describe("TerminalOpenInput", () => { |
| 42 | expect( | 44 | expect( |
| 43 | decodes(TerminalOpenInput, { | 45 | decodes(TerminalOpenInput, { |
| 44 | threadId: "thread-1", | 46 | threadId: "thread-1", |
| 47 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 45 | cwd: "/tmp/project", | 48 | cwd: "/tmp/project", |
| 46 | cols: 423, | 49 | cols: 423, |
| 47 | rows: 40, | 50 | rows: 40, |
| ... | @@ -53,6 +56,7 @@ describe("TerminalOpenInput", () => { | ... | @@ -53,6 +56,7 @@ describe("TerminalOpenInput", () => { |
| 53 | expect( | 56 | expect( |
| 54 | decodes(TerminalOpenInput, { | 57 | decodes(TerminalOpenInput, { |
| 55 | threadId: "thread-1", | 58 | threadId: "thread-1", |
| 59 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 56 | cwd: "/tmp/project", | 60 | cwd: "/tmp/project", |
| 57 | cols: 10, | 61 | cols: 10, |
| 58 | rows: 0, | 62 | rows: 0, |
| ... | @@ -60,19 +64,21 @@ describe("TerminalOpenInput", () => { | ... | @@ -60,19 +64,21 @@ describe("TerminalOpenInput", () => { |
| 60 | ).toBe(false); | 64 | ).toBe(false); |
| 61 | }); | 65 | }); |
| 62 | 66 | ||
| 63 | it("defaults terminalId when missing", () => { | 67 | it("requires terminalId — the client must always pick an id", () => { |
| 64 | const parsed = decodeSync(TerminalOpenInput, { | 68 | expect( |
| 65 | threadId: "thread-1", | 69 | decodes(TerminalOpenInput, { |
| 66 | cwd: "/tmp/project", | 70 | threadId: "thread-1", |
| 67 | cols: 100, | 71 | cwd: "/tmp/project", |
| 68 | rows: 24, | 72 | cols: 100, |
| 69 | }); | 73 | rows: 24, |
| 70 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); | 74 | }), |
| 75 | ).toBe(false); | ||
| 71 | }); | 76 | }); |
| 72 | 77 | ||
| 73 | it("accepts optional env overrides", () => { | 78 | it("accepts optional env overrides", () => { |
| 74 | const parsed = decodeSync(TerminalOpenInput, { | 79 | const parsed = decodeSync(TerminalOpenInput, { |
| 75 | threadId: "thread-1", | 80 | threadId: "thread-1", |
| 81 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 76 | cwd: "/tmp/project", | 82 | cwd: "/tmp/project", |
| 77 | worktreePath: "/tmp/project/.t3/worktrees/feature-a", | 83 | worktreePath: "/tmp/project/.t3/worktrees/feature-a", |
| 78 | cols: 100, | 84 | cols: 100, |
| ... | @@ -104,11 +110,25 @@ describe("TerminalOpenInput", () => { | ... | @@ -104,11 +110,25 @@ describe("TerminalOpenInput", () => { |
| 104 | }); | 110 | }); |
| 105 | }); | 111 | }); |
| 106 | 112 | ||
| 113 | describe("TerminalAttachInput", () => { | ||
| 114 | it("accepts explicit inactive-session restart intent", () => { | ||
| 115 | const parsed = decodeSync(TerminalAttachInput, { | ||
| 116 | threadId: "thread-1", | ||
| 117 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 118 | cwd: "/tmp/project", | ||
| 119 | restartIfNotRunning: true, | ||
| 120 | }); | ||
| 121 | |||
| 122 | expect(parsed.restartIfNotRunning).toBe(true); | ||
| 123 | }); | ||
| 124 | }); | ||
| 125 | |||
| 107 | describe("TerminalWriteInput", () => { | 126 | describe("TerminalWriteInput", () => { |
| 108 | it("accepts non-empty data", () => { | 127 | it("accepts non-empty data", () => { |
| 109 | expect( | 128 | expect( |
| 110 | decodes(TerminalWriteInput, { | 129 | decodes(TerminalWriteInput, { |
| 111 | threadId: "thread-1", | 130 | threadId: "thread-1", |
| 131 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 112 | data: "echo hello\n", | 132 | data: "echo hello\n", |
| 113 | }), | 133 | }), |
| 114 | ).toBe(true); | 134 | ).toBe(true); |
| ... | @@ -118,10 +138,20 @@ describe("TerminalWriteInput", () => { | ... | @@ -118,10 +138,20 @@ describe("TerminalWriteInput", () => { |
| 118 | expect( | 138 | expect( |
| 119 | decodes(TerminalWriteInput, { | 139 | decodes(TerminalWriteInput, { |
| 120 | threadId: "thread-1", | 140 | threadId: "thread-1", |
| 141 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 121 | data: "", | 142 | data: "", |
| 122 | }), | 143 | }), |
| 123 | ).toBe(false); | 144 | ).toBe(false); |
| 124 | }); | 145 | }); |
| 146 | |||
| 147 | it("rejects missing terminalId", () => { | ||
| 148 | expect( | ||
| 149 | decodes(TerminalWriteInput, { | ||
| 150 | threadId: "thread-1", | ||
| 151 | data: "echo hello\n", | ||
| 152 | }), | ||
| 153 | ).toBe(false); | ||
| 154 | }); | ||
| 125 | }); | 155 | }); |
| 126 | 156 | ||
| 127 | describe("TerminalThreadInput", () => { | 157 | describe("TerminalThreadInput", () => { |
| ... | @@ -136,17 +166,33 @@ describe("TerminalResizeInput", () => { | ... | @@ -136,17 +166,33 @@ describe("TerminalResizeInput", () => { |
| 136 | expect( | 166 | expect( |
| 137 | decodes(TerminalResizeInput, { | 167 | decodes(TerminalResizeInput, { |
| 138 | threadId: "thread-1", | 168 | threadId: "thread-1", |
| 169 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 139 | cols: 80, | 170 | cols: 80, |
| 140 | rows: 24, | 171 | rows: 24, |
| 141 | }), | 172 | }), |
| 142 | ).toBe(true); | 173 | ).toBe(true); |
| 143 | }); | 174 | }); |
| 175 | |||
| 176 | it("rejects missing terminalId", () => { | ||
| 177 | expect( | ||
| 178 | decodes(TerminalResizeInput, { | ||
| 179 | threadId: "thread-1", | ||
| 180 | cols: 80, | ||
| 181 | rows: 24, | ||
| 182 | }), | ||
| 183 | ).toBe(false); | ||
| 184 | }); | ||
| 144 | }); | 185 | }); |
| 145 | 186 | ||
| 146 | describe("TerminalClearInput", () => { | 187 | describe("TerminalClearInput", () => { |
| 147 | it("defaults terminal id", () => { | 188 | it("requires terminalId", () => { |
| 189 | expect(decodes(TerminalClearInput, { threadId: "thread-1" })).toBe(false); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it("accepts an explicit terminalId", () => { | ||
| 148 | const parsed = decodeSync(TerminalClearInput, { | 193 | const parsed = decodeSync(TerminalClearInput, { |
| 149 | threadId: "thread-1", | 194 | threadId: "thread-1", |
| 195 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 150 | }); | 196 | }); |
| 151 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); | 197 | expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); |
| 152 | }); | 198 | }); |
| ... | @@ -178,6 +224,7 @@ describe("TerminalSessionSnapshot", () => { | ... | @@ -178,6 +224,7 @@ describe("TerminalSessionSnapshot", () => { |
| 178 | history: "hello\n", | 224 | history: "hello\n", |
| 179 | exitCode: null, | 225 | exitCode: null, |
| 180 | exitSignal: null, | 226 | exitSignal: null, |
| 227 | label: "Primary", | ||
| 181 | updatedAt: isoTimestamp, | 228 | updatedAt: isoTimestamp, |
| 182 | }), | 229 | }), |
| 183 | ).toBe(true); | 230 | ).toBe(true); |
| ... | @@ -193,7 +240,6 @@ describe("TerminalEvent", () => { | ... | @@ -193,7 +240,6 @@ describe("TerminalEvent", () => { |
| 193 | type: "output", | 240 | type: "output", |
| 194 | threadId: "thread-1", | 241 | threadId: "thread-1", |
| 195 | terminalId: DEFAULT_TERMINAL_ID, | 242 | terminalId: DEFAULT_TERMINAL_ID, |
| 196 | createdAt: isoTimestamp, | ||
| 197 | data: "line\n", | 243 | data: "line\n", |
| 198 | }), | 244 | }), |
| 199 | ).toBe(true); | 245 | ).toBe(true); |
| ... | @@ -205,21 +251,30 @@ describe("TerminalEvent", () => { | ... | @@ -205,21 +251,30 @@ describe("TerminalEvent", () => { |
| 205 | type: "exited", | 251 | type: "exited", |
| 206 | threadId: "thread-1", | 252 | threadId: "thread-1", |
| 207 | terminalId: DEFAULT_TERMINAL_ID, | 253 | terminalId: DEFAULT_TERMINAL_ID, |
| 208 | createdAt: isoTimestamp, | ||
| 209 | exitCode: 0, | 254 | exitCode: 0, |
| 210 | exitSignal: null, | 255 | exitSignal: null, |
| 211 | }), | 256 | }), |
| 212 | ).toBe(true); | 257 | ).toBe(true); |
| 213 | }); | 258 | }); |
| 214 | 259 | ||
| 260 | it("accepts closed events", () => { | ||
| 261 | expect( | ||
| 262 | decodes(TerminalEvent, { | ||
| 263 | type: "closed", | ||
| 264 | threadId: "thread-1", | ||
| 265 | terminalId: DEFAULT_TERMINAL_ID, | ||
| 266 | }), | ||
| 267 | ).toBe(true); | ||
| 268 | }); | ||
| 269 | |||
| 215 | it("accepts activity events", () => { | 270 | it("accepts activity events", () => { |
| 216 | expect( | 271 | expect( |
| 217 | decodes(TerminalEvent, { | 272 | decodes(TerminalEvent, { |
| 218 | type: "activity", | 273 | type: "activity", |
| 219 | threadId: "thread-1", | 274 | threadId: "thread-1", |
| 220 | terminalId: DEFAULT_TERMINAL_ID, | 275 | terminalId: DEFAULT_TERMINAL_ID, |
| 221 | createdAt: isoTimestamp, | ||
| 222 | hasRunningSubprocess: true, | 276 | hasRunningSubprocess: true, |
| 277 | label: "vim", | ||
| 223 | }), | 278 | }), |
| 224 | ).toBe(true); | 279 | ).toBe(true); |
| 225 | }); | 280 | }); |
| ... | @@ -230,7 +285,6 @@ describe("TerminalEvent", () => { | ... | @@ -230,7 +285,6 @@ describe("TerminalEvent", () => { |
| 230 | type: "started", | 285 | type: "started", |
| 231 | threadId: "thread-1", | 286 | threadId: "thread-1", |
| 232 | terminalId: DEFAULT_TERMINAL_ID, | 287 | terminalId: DEFAULT_TERMINAL_ID, |
| 233 | createdAt: isoTimestamp, | ||
| 234 | snapshot: { | 288 | snapshot: { |
| 235 | threadId: "thread-1", | 289 | threadId: "thread-1", |
| 236 | terminalId: DEFAULT_TERMINAL_ID, | 290 | terminalId: DEFAULT_TERMINAL_ID, |
| ... | @@ -241,6 +295,7 @@ describe("TerminalEvent", () => { | ... | @@ -241,6 +295,7 @@ describe("TerminalEvent", () => { |
| 241 | history: "", | 295 | history: "", |
| 242 | exitCode: null, | 296 | exitCode: null, |
| 243 | exitSignal: null, | 297 | exitSignal: null, |
| 298 | label: "Primary", | ||
| 244 | updatedAt: isoTimestamp, | 299 | updatedAt: isoTimestamp, |
| 245 | }, | 300 | }, |
| 246 | }), | 301 | }), |
packages/contracts/src/terminal.ts+86-8| ... | @@ -1,8 +1,12 @@ | ... | @@ -1,8 +1,12 @@ |
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 3 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | 3 | ||
| 5 | export const DEFAULT_TERMINAL_ID = "default"; | 4 | /** |
| 5 | * Client-side id for the first shell opened on a thread. Ids are uniformly | ||
| 6 | * `term-N`; there's no "default" intrinsic. Kept as a named constant so callers | ||
| 7 | * that want "the primary shell" don't hardcode `"term-1"`. | ||
| 8 | */ | ||
| 9 | export const DEFAULT_TERMINAL_ID = "term-1"; | ||
| 6 | 10 | ||
| 7 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | 11 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 8 | const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check( | 12 | const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check( |
| ... | @@ -20,18 +24,15 @@ const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSc | ... | @@ -20,18 +24,15 @@ const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSc |
| 20 | Schema.isMaxProperties(128), | 24 | Schema.isMaxProperties(128), |
| 21 | ); | 25 | ); |
| 22 | 26 | ||
| 23 | const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe( | ||
| 24 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_TERMINAL_ID)), | ||
| 25 | ); | ||
| 26 | |||
| 27 | export const TerminalThreadInput = Schema.Struct({ | 27 | export const TerminalThreadInput = Schema.Struct({ |
| 28 | threadId: TrimmedNonEmptyStringSchema, | 28 | threadId: TrimmedNonEmptyStringSchema, |
| 29 | }); | 29 | }); |
| 30 | export type TerminalThreadInput = typeof TerminalThreadInput.Type; | 30 | export type TerminalThreadInput = typeof TerminalThreadInput.Type; |
| 31 | 31 | ||
| 32 | /** Terminal ids are ALWAYS chosen by the client and sent explicitly — no server-side allocation. */ | ||
| 32 | const TerminalSessionInput = Schema.Struct({ | 33 | const TerminalSessionInput = Schema.Struct({ |
| 33 | ...TerminalThreadInput.fields, | 34 | ...TerminalThreadInput.fields, |
| 34 | terminalId: TerminalIdWithDefaultSchema, | 35 | terminalId: TerminalIdSchema, |
| 35 | }); | 36 | }); |
| 36 | export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>; | 37 | export type TerminalSessionInput = Schema.Codec.Encoded<typeof TerminalSessionInput>; |
| 37 | 38 | ||
| ... | @@ -45,6 +46,17 @@ export const TerminalOpenInput = Schema.Struct({ | ... | @@ -45,6 +46,17 @@ export const TerminalOpenInput = Schema.Struct({ |
| 45 | }); | 46 | }); |
| 46 | export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>; | 47 | export type TerminalOpenInput = Schema.Codec.Encoded<typeof TerminalOpenInput>; |
| 47 | 48 | ||
| 49 | export const TerminalAttachInput = Schema.Struct({ | ||
| 50 | ...TerminalSessionInput.fields, | ||
| 51 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 52 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), | ||
| 53 | cols: Schema.optional(TerminalColsSchema), | ||
| 54 | rows: Schema.optional(TerminalRowsSchema), | ||
| 55 | env: Schema.optional(TerminalEnvSchema), | ||
| 56 | restartIfNotRunning: Schema.optional(Schema.Boolean), | ||
| 57 | }); | ||
| 58 | export type TerminalAttachInput = Schema.Codec.Encoded<typeof TerminalAttachInput>; | ||
| 59 | |||
| 48 | export const TerminalWriteInput = Schema.Struct({ | 60 | export const TerminalWriteInput = Schema.Struct({ |
| 49 | ...TerminalSessionInput.fields, | 61 | ...TerminalSessionInput.fields, |
| 50 | data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)), | 62 | data: Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536)), |
| ... | @@ -91,14 +103,56 @@ export const TerminalSessionSnapshot = Schema.Struct({ | ... | @@ -91,14 +103,56 @@ export const TerminalSessionSnapshot = Schema.Struct({ |
| 91 | history: Schema.String, | 103 | history: Schema.String, |
| 92 | exitCode: Schema.NullOr(Schema.Int), | 104 | exitCode: Schema.NullOr(Schema.Int), |
| 93 | exitSignal: Schema.NullOr(Schema.Int), | 105 | exitSignal: Schema.NullOr(Schema.Int), |
| 106 | /** Server-computed display title (idle shell vs subprocess command). */ | ||
| 107 | label: Schema.String.check(Schema.isMaxLength(128)), | ||
| 94 | updatedAt: Schema.String, | 108 | updatedAt: Schema.String, |
| 109 | sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), | ||
| 95 | }); | 110 | }); |
| 96 | export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type; | 111 | export type TerminalSessionSnapshot = typeof TerminalSessionSnapshot.Type; |
| 97 | 112 | ||
| 113 | export const TerminalSummary = Schema.Struct({ | ||
| 114 | threadId: Schema.String.check(Schema.isNonEmpty()), | ||
| 115 | terminalId: Schema.String.check(Schema.isNonEmpty()), | ||
| 116 | cwd: Schema.String.check(Schema.isNonEmpty()), | ||
| 117 | worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema), | ||
| 118 | status: TerminalSessionStatus, | ||
| 119 | pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), | ||
| 120 | exitCode: Schema.NullOr(Schema.Int), | ||
| 121 | exitSignal: Schema.NullOr(Schema.Int), | ||
| 122 | hasRunningSubprocess: Schema.Boolean, | ||
| 123 | /** Server-computed display title (idle shell vs subprocess command). */ | ||
| 124 | label: Schema.String.check(Schema.isMaxLength(128)), | ||
| 125 | updatedAt: Schema.String, | ||
| 126 | }); | ||
| 127 | export type TerminalSummary = typeof TerminalSummary.Type; | ||
| 128 | |||
| 129 | const TerminalMetadataSnapshotEvent = Schema.Struct({ | ||
| 130 | type: Schema.Literal("snapshot"), | ||
| 131 | terminals: Schema.Array(TerminalSummary), | ||
| 132 | }); | ||
| 133 | |||
| 134 | const TerminalMetadataUpsertEvent = Schema.Struct({ | ||
| 135 | type: Schema.Literal("upsert"), | ||
| 136 | terminal: TerminalSummary, | ||
| 137 | }); | ||
| 138 | |||
| 139 | const TerminalMetadataRemoveEvent = Schema.Struct({ | ||
| 140 | type: Schema.Literal("remove"), | ||
| 141 | threadId: Schema.String.check(Schema.isNonEmpty()), | ||
| 142 | terminalId: Schema.String.check(Schema.isNonEmpty()), | ||
| 143 | }); | ||
| 144 | |||
| 145 | export const TerminalMetadataStreamEvent = Schema.Union([ | ||
| 146 | TerminalMetadataSnapshotEvent, | ||
| 147 | TerminalMetadataUpsertEvent, | ||
| 148 | TerminalMetadataRemoveEvent, | ||
| 149 | ]); | ||
| 150 | export type TerminalMetadataStreamEvent = typeof TerminalMetadataStreamEvent.Type; | ||
| 151 | |||
| 98 | const TerminalEventBaseSchema = Schema.Struct({ | 152 | const TerminalEventBaseSchema = Schema.Struct({ |
| 99 | threadId: Schema.String.check(Schema.isNonEmpty()), | 153 | threadId: Schema.String.check(Schema.isNonEmpty()), |
| 100 | terminalId: Schema.String.check(Schema.isNonEmpty()), | 154 | terminalId: Schema.String.check(Schema.isNonEmpty()), |
| 101 | createdAt: Schema.String, | 155 | sequence: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), |
| 102 | }); | 156 | }); |
| 103 | 157 | ||
| 104 | const TerminalStartedEvent = Schema.Struct({ | 158 | const TerminalStartedEvent = Schema.Struct({ |
| ... | @@ -120,6 +174,11 @@ const TerminalExitedEvent = Schema.Struct({ | ... | @@ -120,6 +174,11 @@ const TerminalExitedEvent = Schema.Struct({ |
| 120 | exitSignal: Schema.NullOr(Schema.Int), | 174 | exitSignal: Schema.NullOr(Schema.Int), |
| 121 | }); | 175 | }); |
| 122 | 176 | ||
| 177 | const TerminalClosedEvent = Schema.Struct({ | ||
| 178 | ...TerminalEventBaseSchema.fields, | ||
| 179 | type: Schema.Literal("closed"), | ||
| 180 | }); | ||
| 181 | |||
| 123 | const TerminalErrorEvent = Schema.Struct({ | 182 | const TerminalErrorEvent = Schema.Struct({ |
| 124 | ...TerminalEventBaseSchema.fields, | 183 | ...TerminalEventBaseSchema.fields, |
| 125 | type: Schema.Literal("error"), | 184 | type: Schema.Literal("error"), |
| ... | @@ -141,12 +200,14 @@ const TerminalActivityEvent = Schema.Struct({ | ... | @@ -141,12 +200,14 @@ const TerminalActivityEvent = Schema.Struct({ |
| 141 | ...TerminalEventBaseSchema.fields, | 200 | ...TerminalEventBaseSchema.fields, |
| 142 | type: Schema.Literal("activity"), | 201 | type: Schema.Literal("activity"), |
| 143 | hasRunningSubprocess: Schema.Boolean, | 202 | hasRunningSubprocess: Schema.Boolean, |
| 203 | label: Schema.String.check(Schema.isMaxLength(128)), | ||
| 144 | }); | 204 | }); |
| 145 | 205 | ||
| 146 | export const TerminalEvent = Schema.Union([ | 206 | export const TerminalEvent = Schema.Union([ |
| 147 | TerminalStartedEvent, | 207 | TerminalStartedEvent, |
| 148 | TerminalOutputEvent, | 208 | TerminalOutputEvent, |
| 149 | TerminalExitedEvent, | 209 | TerminalExitedEvent, |
| 210 | TerminalClosedEvent, | ||
| 150 | TerminalErrorEvent, | 211 | TerminalErrorEvent, |
| 151 | TerminalClearedEvent, | 212 | TerminalClearedEvent, |
| 152 | TerminalRestartedEvent, | 213 | TerminalRestartedEvent, |
| ... | @@ -154,6 +215,23 @@ export const TerminalEvent = Schema.Union([ | ... | @@ -154,6 +215,23 @@ export const TerminalEvent = Schema.Union([ |
| 154 | ]); | 215 | ]); |
| 155 | export type TerminalEvent = typeof TerminalEvent.Type; | 216 | export type TerminalEvent = typeof TerminalEvent.Type; |
| 156 | 217 | ||
| 218 | const TerminalAttachSnapshotEvent = Schema.Struct({ | ||
| 219 | type: Schema.Literal("snapshot"), | ||
| 220 | snapshot: TerminalSessionSnapshot, | ||
| 221 | }); | ||
| 222 | |||
| 223 | export const TerminalAttachStreamEvent = Schema.Union([ | ||
| 224 | TerminalAttachSnapshotEvent, | ||
| 225 | TerminalOutputEvent, | ||
| 226 | TerminalExitedEvent, | ||
| 227 | TerminalClosedEvent, | ||
| 228 | TerminalErrorEvent, | ||
| 229 | TerminalClearedEvent, | ||
| 230 | TerminalRestartedEvent, | ||
| 231 | TerminalActivityEvent, | ||
| 232 | ]); | ||
| 233 | export type TerminalAttachStreamEvent = typeof TerminalAttachStreamEvent.Type; | ||
| 234 | |||
| 157 | export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()( | 235 | export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>()( |
| 158 | "TerminalCwdError", | 236 | "TerminalCwdError", |
| 159 | { | 237 | { |
packages/shared/package.json+16| ... | @@ -71,6 +71,14 @@ | ... | @@ -71,6 +71,14 @@ |
| 71 | "types": "./src/projectScripts.ts", | 71 | "types": "./src/projectScripts.ts", |
| 72 | "import": "./src/projectScripts.ts" | 72 | "import": "./src/projectScripts.ts" |
| 73 | }, | 73 | }, |
| 74 | "./orchestrationTiming": { | ||
| 75 | "types": "./src/orchestrationTiming.ts", | ||
| 76 | "import": "./src/orchestrationTiming.ts" | ||
| 77 | }, | ||
| 78 | "./remote": { | ||
| 79 | "types": "./src/remote.ts", | ||
| 80 | "import": "./src/remote.ts" | ||
| 81 | }, | ||
| 74 | "./searchRanking": { | 82 | "./searchRanking": { |
| 75 | "types": "./src/searchRanking.ts", | 83 | "types": "./src/searchRanking.ts", |
| 76 | "import": "./src/searchRanking.ts" | 84 | "import": "./src/searchRanking.ts" |
| ... | @@ -90,6 +98,14 @@ | ... | @@ -90,6 +98,14 @@ |
| 90 | "./keybindings": { | 98 | "./keybindings": { |
| 91 | "types": "./src/keybindings.ts", | 99 | "types": "./src/keybindings.ts", |
| 92 | "import": "./src/keybindings.ts" | 100 | "import": "./src/keybindings.ts" |
| 101 | }, | ||
| 102 | "./composerTrigger": { | ||
| 103 | "types": "./src/composerTrigger.ts", | ||
| 104 | "import": "./src/composerTrigger.ts" | ||
| 105 | }, | ||
| 106 | "./terminalLabels": { | ||
| 107 | "types": "./src/terminalLabels.ts", | ||
| 108 | "import": "./src/terminalLabels.ts" | ||
| 93 | } | 109 | } |
| 94 | }, | 110 | }, |
| 95 | "scripts": { | 111 | "scripts": { |
packages/shared/src/composerTrigger.ts created+117| ... | @@ -0,0 +1,117 @@ | ||
| 1 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill"; | ||
| 2 | export type ComposerSlashCommand = "model" | "plan" | "default"; | ||
| 3 | |||
| 4 | export interface ComposerTrigger { | ||
| 5 | kind: ComposerTriggerKind; | ||
| 6 | query: string; | ||
| 7 | rangeStart: number; | ||
| 8 | rangeEnd: number; | ||
| 9 | } | ||
| 10 | |||
| 11 | function clampCursor(text: string, cursor: number): number { | ||
| 12 | if (!Number.isFinite(cursor)) return text.length; | ||
| 13 | return Math.max(0, Math.min(text.length, Math.floor(cursor))); | ||
| 14 | } | ||
| 15 | |||
| 16 | function isWhitespace(char: string): boolean { | ||
| 17 | return char === " " || char === "\n" || char === "\t" || char === "\r"; | ||
| 18 | } | ||
| 19 | |||
| 20 | /** | ||
| 21 | * Detect an active trigger (@path, $skill, /command) at the cursor position. | ||
| 22 | * | ||
| 23 | * Accepts an optional `isWhitespaceChar` override so callers with inline | ||
| 24 | * placeholder characters (e.g. terminal context chips on web) can treat | ||
| 25 | * those as token boundaries. | ||
| 26 | */ | ||
| 27 | export function detectComposerTrigger( | ||
| 28 | text: string, | ||
| 29 | cursorInput: number, | ||
| 30 | isWhitespaceChar?: (char: string) => boolean, | ||
| 31 | ): ComposerTrigger | null { | ||
| 32 | const cursor = clampCursor(text, cursorInput); | ||
| 33 | const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; | ||
| 34 | const linePrefix = text.slice(lineStart, cursor); | ||
| 35 | |||
| 36 | if (linePrefix.startsWith("/")) { | ||
| 37 | const commandMatch = /^\/(\S*)$/.exec(linePrefix); | ||
| 38 | if (commandMatch) { | ||
| 39 | const commandQuery = commandMatch[1] ?? ""; | ||
| 40 | if (commandQuery.toLowerCase() === "model") { | ||
| 41 | return { | ||
| 42 | kind: "slash-model", | ||
| 43 | query: "", | ||
| 44 | rangeStart: lineStart, | ||
| 45 | rangeEnd: cursor, | ||
| 46 | }; | ||
| 47 | } | ||
| 48 | return { | ||
| 49 | kind: "slash-command", | ||
| 50 | query: commandQuery, | ||
| 51 | rangeStart: lineStart, | ||
| 52 | rangeEnd: cursor, | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | |||
| 56 | const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); | ||
| 57 | if (modelMatch) { | ||
| 58 | return { | ||
| 59 | kind: "slash-model", | ||
| 60 | query: (modelMatch[1] ?? "").trim(), | ||
| 61 | rangeStart: lineStart, | ||
| 62 | rangeEnd: cursor, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | const wsCheck = isWhitespaceChar ?? isWhitespace; | ||
| 68 | let tokenIdx = cursor - 1; | ||
| 69 | while (tokenIdx >= 0 && !wsCheck(text[tokenIdx] ?? "")) { | ||
| 70 | tokenIdx -= 1; | ||
| 71 | } | ||
| 72 | const tokenStart = tokenIdx + 1; | ||
| 73 | |||
| 74 | const token = text.slice(tokenStart, cursor); | ||
| 75 | if (token.startsWith("$")) { | ||
| 76 | return { | ||
| 77 | kind: "skill", | ||
| 78 | query: token.slice(1), | ||
| 79 | rangeStart: tokenStart, | ||
| 80 | rangeEnd: cursor, | ||
| 81 | }; | ||
| 82 | } | ||
| 83 | if (!token.startsWith("@")) { | ||
| 84 | return null; | ||
| 85 | } | ||
| 86 | |||
| 87 | return { | ||
| 88 | kind: "path", | ||
| 89 | query: token.slice(1), | ||
| 90 | rangeStart: tokenStart, | ||
| 91 | rangeEnd: cursor, | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | |||
| 95 | export function parseStandaloneComposerSlashCommand( | ||
| 96 | text: string, | ||
| 97 | ): Exclude<ComposerSlashCommand, "model"> | null { | ||
| 98 | const match = /^\/(plan|default)\s*$/i.exec(text.trim()); | ||
| 99 | if (!match) { | ||
| 100 | return null; | ||
| 101 | } | ||
| 102 | const command = match[1]?.toLowerCase(); | ||
| 103 | if (command === "plan") return "plan"; | ||
| 104 | return "default"; | ||
| 105 | } | ||
| 106 | |||
| 107 | export function replaceTextRange( | ||
| 108 | text: string, | ||
| 109 | rangeStart: number, | ||
| 110 | rangeEnd: number, | ||
| 111 | replacement: string, | ||
| 112 | ): { text: string; cursor: number } { | ||
| 113 | const safeStart = Math.max(0, Math.min(text.length, rangeStart)); | ||
| 114 | const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd)); | ||
| 115 | const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; | ||
| 116 | return { text: nextText, cursor: safeStart + replacement.length }; | ||
| 117 | } | ||
packages/shared/src/orchestrationTiming.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | type LatestTurnTiming = { | ||
| 2 | readonly turnId: string | null; | ||
| 3 | readonly startedAt: string | null; | ||
| 4 | readonly completedAt: string | null; | ||
| 5 | }; | ||
| 6 | |||
| 7 | type SessionActivityState = { | ||
| 8 | readonly orchestrationStatus: string; | ||
| 9 | readonly activeTurnId?: string | null; | ||
| 10 | }; | ||
| 11 | |||
| 12 | export function formatDuration(durationMs: number): string { | ||
| 13 | if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; | ||
| 14 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; | ||
| 15 | if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; | ||
| 16 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; | ||
| 17 | const minutes = Math.floor(durationMs / 60_000); | ||
| 18 | const seconds = Math.round((durationMs % 60_000) / 1_000); | ||
| 19 | if (seconds === 0) return `${minutes}m`; | ||
| 20 | if (seconds === 60) return `${minutes + 1}m`; | ||
| 21 | return `${minutes}m ${seconds}s`; | ||
| 22 | } | ||
| 23 | |||
| 24 | export function formatElapsed(startIso: string, endIso: string | undefined): string | null { | ||
| 25 | if (!endIso) return null; | ||
| 26 | const startedAt = Date.parse(startIso); | ||
| 27 | const endedAt = Date.parse(endIso); | ||
| 28 | if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { | ||
| 29 | return null; | ||
| 30 | } | ||
| 31 | return formatDuration(endedAt - startedAt); | ||
| 32 | } | ||
| 33 | |||
| 34 | export function isLatestTurnSettled( | ||
| 35 | latestTurn: LatestTurnTiming | null, | ||
| 36 | session: SessionActivityState | null, | ||
| 37 | ): boolean { | ||
| 38 | if (!latestTurn?.startedAt) return false; | ||
| 39 | if (!latestTurn.completedAt) return false; | ||
| 40 | if (!session) return true; | ||
| 41 | if (session.orchestrationStatus === "running") return false; | ||
| 42 | return true; | ||
| 43 | } | ||
| 44 | |||
| 45 | export function deriveActiveWorkStartedAt( | ||
| 46 | latestTurn: LatestTurnTiming | null, | ||
| 47 | session: SessionActivityState | null, | ||
| 48 | sendStartedAt: string | null, | ||
| 49 | ): string | null { | ||
| 50 | if (!isLatestTurnSettled(latestTurn, session)) { | ||
| 51 | return latestTurn?.startedAt ?? sendStartedAt; | ||
| 52 | } | ||
| 53 | return sendStartedAt; | ||
| 54 | } | ||
packages/shared/src/remote.test.ts created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { resolveRemotePairingTarget } from "./remote.ts"; | ||
| 4 | |||
| 5 | describe("remote", () => { | ||
| 6 | it("derives backend urls and token from a pairing url", () => { | ||
| 7 | expect( | ||
| 8 | resolveRemotePairingTarget({ | ||
| 9 | pairingUrl: "https://remote.example.com/pair#token=pairing-token", | ||
| 10 | }), | ||
| 11 | ).toEqual({ | ||
| 12 | credential: "pairing-token", | ||
| 13 | httpBaseUrl: "https://remote.example.com/", | ||
| 14 | wsBaseUrl: "wss://remote.example.com/", | ||
| 15 | }); | ||
| 16 | }); | ||
| 17 | |||
| 18 | it("accepts pairing urls that still use a query token", () => { | ||
| 19 | expect( | ||
| 20 | resolveRemotePairingTarget({ | ||
| 21 | pairingUrl: "https://remote.example.com/pair?token=pairing-token", | ||
| 22 | }), | ||
| 23 | ).toEqual({ | ||
| 24 | credential: "pairing-token", | ||
| 25 | httpBaseUrl: "https://remote.example.com/", | ||
| 26 | wsBaseUrl: "wss://remote.example.com/", | ||
| 27 | }); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("derives backend urls from hosted app pairing links", () => { | ||
| 31 | expect( | ||
| 32 | resolveRemotePairingTarget({ | ||
| 33 | pairingUrl: | ||
| 34 | "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%3A44342%2F#token=pairing-token", | ||
| 35 | }), | ||
| 36 | ).toEqual({ | ||
| 37 | credential: "pairing-token", | ||
| 38 | httpBaseUrl: "https://desktop.tailnet.ts.net:44342/", | ||
| 39 | wsBaseUrl: "wss://desktop.tailnet.ts.net:44342/", | ||
| 40 | }); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("derives backend urls from a host and pairing code", () => { | ||
| 44 | expect( | ||
| 45 | resolveRemotePairingTarget({ | ||
| 46 | host: "https://remote.example.com", | ||
| 47 | pairingCode: "pairing-token", | ||
| 48 | }), | ||
| 49 | ).toEqual({ | ||
| 50 | credential: "pairing-token", | ||
| 51 | httpBaseUrl: "https://remote.example.com/", | ||
| 52 | wsBaseUrl: "wss://remote.example.com/", | ||
| 53 | }); | ||
| 54 | }); | ||
| 55 | |||
| 56 | it("preserves host ports when normalizing a bare host input", () => { | ||
| 57 | expect( | ||
| 58 | resolveRemotePairingTarget({ | ||
| 59 | host: "myserver.com:3000", | ||
| 60 | pairingCode: "pairing-token", | ||
| 61 | }), | ||
| 62 | ).toEqual({ | ||
| 63 | credential: "pairing-token", | ||
| 64 | httpBaseUrl: "https://myserver.com:3000/", | ||
| 65 | wsBaseUrl: "wss://myserver.com:3000/", | ||
| 66 | }); | ||
| 67 | }); | ||
| 68 | }); | ||
packages/shared/src/remote.ts created+151| ... | @@ -0,0 +1,151 @@ | ||
| 1 | const PAIRING_TOKEN_PARAM = "token"; | ||
| 2 | const HOSTED_PAIRING_HOST_PARAM = "host"; | ||
| 3 | const HOSTED_PAIRING_LABEL_PARAM = "label"; | ||
| 4 | |||
| 5 | const readHashParams = (url: URL): URLSearchParams => | ||
| 6 | new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); | ||
| 7 | |||
| 8 | const normalizeRemoteBaseUrl = (rawValue: string): URL => { | ||
| 9 | const trimmed = rawValue.trim(); | ||
| 10 | if (!trimmed) { | ||
| 11 | throw new Error("Enter a backend URL."); | ||
| 12 | } | ||
| 13 | |||
| 14 | const normalizedInput = | ||
| 15 | /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//") | ||
| 16 | ? trimmed | ||
| 17 | : `https://${trimmed}`; | ||
| 18 | const url = new URL(normalizedInput); | ||
| 19 | url.pathname = "/"; | ||
| 20 | url.search = ""; | ||
| 21 | url.hash = ""; | ||
| 22 | return url; | ||
| 23 | }; | ||
| 24 | |||
| 25 | const toHttpBaseUrl = (url: URL): string => { | ||
| 26 | const next = new URL(url.toString()); | ||
| 27 | if (next.protocol === "ws:") { | ||
| 28 | next.protocol = "http:"; | ||
| 29 | } else if (next.protocol === "wss:") { | ||
| 30 | next.protocol = "https:"; | ||
| 31 | } | ||
| 32 | next.pathname = "/"; | ||
| 33 | next.search = ""; | ||
| 34 | next.hash = ""; | ||
| 35 | return next.toString(); | ||
| 36 | }; | ||
| 37 | |||
| 38 | const toWsBaseUrl = (url: URL): string => { | ||
| 39 | const next = new URL(url.toString()); | ||
| 40 | if (next.protocol === "http:") { | ||
| 41 | next.protocol = "ws:"; | ||
| 42 | } else if (next.protocol === "https:") { | ||
| 43 | next.protocol = "wss:"; | ||
| 44 | } | ||
| 45 | next.pathname = "/"; | ||
| 46 | next.search = ""; | ||
| 47 | next.hash = ""; | ||
| 48 | return next.toString(); | ||
| 49 | }; | ||
| 50 | |||
| 51 | export interface ResolvedRemotePairingTarget { | ||
| 52 | readonly credential: string; | ||
| 53 | readonly httpBaseUrl: string; | ||
| 54 | readonly wsBaseUrl: string; | ||
| 55 | } | ||
| 56 | |||
| 57 | export interface HostedPairingRequest { | ||
| 58 | readonly host: string; | ||
| 59 | readonly token: string; | ||
| 60 | readonly label: string; | ||
| 61 | } | ||
| 62 | |||
| 63 | export const getPairingTokenFromUrl = (url: URL): string | null => { | ||
| 64 | const hashToken = readHashParams(url).get(PAIRING_TOKEN_PARAM)?.trim() ?? ""; | ||
| 65 | if (hashToken.length > 0) { | ||
| 66 | return hashToken; | ||
| 67 | } | ||
| 68 | |||
| 69 | const searchToken = url.searchParams.get(PAIRING_TOKEN_PARAM)?.trim() ?? ""; | ||
| 70 | return searchToken.length > 0 ? searchToken : null; | ||
| 71 | }; | ||
| 72 | |||
| 73 | export const stripPairingTokenFromUrl = (url: URL): URL => { | ||
| 74 | const next = new URL(url.toString()); | ||
| 75 | const hashParams = readHashParams(next); | ||
| 76 | if (hashParams.has(PAIRING_TOKEN_PARAM)) { | ||
| 77 | hashParams.delete(PAIRING_TOKEN_PARAM); | ||
| 78 | next.hash = hashParams.toString(); | ||
| 79 | } | ||
| 80 | next.searchParams.delete(PAIRING_TOKEN_PARAM); | ||
| 81 | return next; | ||
| 82 | }; | ||
| 83 | |||
| 84 | export const setPairingTokenOnUrl = (url: URL, credential: string): URL => { | ||
| 85 | const next = new URL(url.toString()); | ||
| 86 | next.searchParams.delete(PAIRING_TOKEN_PARAM); | ||
| 87 | next.hash = new URLSearchParams([[PAIRING_TOKEN_PARAM, credential]]).toString(); | ||
| 88 | return next; | ||
| 89 | }; | ||
| 90 | |||
| 91 | export const readHostedPairingRequest = (url: URL): HostedPairingRequest | null => { | ||
| 92 | const host = url.searchParams.get(HOSTED_PAIRING_HOST_PARAM)?.trim() ?? ""; | ||
| 93 | const token = getPairingTokenFromUrl(url)?.trim() ?? ""; | ||
| 94 | const label = url.searchParams.get(HOSTED_PAIRING_LABEL_PARAM)?.trim() ?? ""; | ||
| 95 | |||
| 96 | if (!host || !token) { | ||
| 97 | return null; | ||
| 98 | } | ||
| 99 | |||
| 100 | return { | ||
| 101 | host, | ||
| 102 | token, | ||
| 103 | label, | ||
| 104 | }; | ||
| 105 | }; | ||
| 106 | |||
| 107 | export const resolveRemotePairingTarget = (input: { | ||
| 108 | readonly pairingUrl?: string; | ||
| 109 | readonly host?: string; | ||
| 110 | readonly pairingCode?: string; | ||
| 111 | }): ResolvedRemotePairingTarget => { | ||
| 112 | const pairingUrl = input.pairingUrl?.trim() ?? ""; | ||
| 113 | if (pairingUrl.length > 0) { | ||
| 114 | const url = new URL(pairingUrl); | ||
| 115 | const hostedPairingRequest = readHostedPairingRequest(url); | ||
| 116 | if (hostedPairingRequest) { | ||
| 117 | const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host); | ||
| 118 | return { | ||
| 119 | credential: hostedPairingRequest.token, | ||
| 120 | httpBaseUrl: toHttpBaseUrl(hostedBackendUrl), | ||
| 121 | wsBaseUrl: toWsBaseUrl(hostedBackendUrl), | ||
| 122 | }; | ||
| 123 | } | ||
| 124 | |||
| 125 | const credential = getPairingTokenFromUrl(url) ?? ""; | ||
| 126 | if (!credential) { | ||
| 127 | throw new Error("Pairing URL is missing its token."); | ||
| 128 | } | ||
| 129 | return { | ||
| 130 | credential, | ||
| 131 | httpBaseUrl: toHttpBaseUrl(url), | ||
| 132 | wsBaseUrl: toWsBaseUrl(url), | ||
| 133 | }; | ||
| 134 | } | ||
| 135 | |||
| 136 | const host = input.host?.trim() ?? ""; | ||
| 137 | const pairingCode = input.pairingCode?.trim() ?? ""; | ||
| 138 | if (!host) { | ||
| 139 | throw new Error("Enter a backend URL."); | ||
| 140 | } | ||
| 141 | if (!pairingCode) { | ||
| 142 | throw new Error("Enter a pairing code."); | ||
| 143 | } | ||
| 144 | |||
| 145 | const normalizedHost = normalizeRemoteBaseUrl(host); | ||
| 146 | return { | ||
| 147 | credential: pairingCode, | ||
| 148 | httpBaseUrl: toHttpBaseUrl(normalizedHost), | ||
| 149 | wsBaseUrl: toWsBaseUrl(normalizedHost), | ||
| 150 | }; | ||
| 151 | }; | ||
packages/shared/src/terminalLabels.test.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import type { TerminalSummary } from "@t3tools/contracts"; | ||
| 4 | import { DEFAULT_TERMINAL_ID } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import { getTerminalLabel, nextTerminalId, resolveTerminalSessionLabel } from "./terminalLabels.ts"; | ||
| 7 | |||
| 8 | describe("getTerminalLabel", () => { | ||
| 9 | it("uses the numeric suffix for term-* ids", () => { | ||
| 10 | expect(getTerminalLabel(DEFAULT_TERMINAL_ID)).toBe("Terminal 1"); | ||
| 11 | expect(getTerminalLabel("term-2")).toBe("Terminal 2"); | ||
| 12 | expect(getTerminalLabel("term-12")).toBe("Terminal 12"); | ||
| 13 | expect(getTerminalLabel("terminal-3")).toBe("Terminal 3"); | ||
| 14 | }); | ||
| 15 | |||
| 16 | it("falls back to the raw id for unknown shapes", () => { | ||
| 17 | expect(getTerminalLabel("custom-session")).toBe("custom-session"); | ||
| 18 | }); | ||
| 19 | }); | ||
| 20 | |||
| 21 | describe("resolveTerminalSessionLabel", () => { | ||
| 22 | it("prefers a non-empty summary label", () => { | ||
| 23 | const summary = { label: " bun " } as Pick<TerminalSummary, "label">; | ||
| 24 | expect(resolveTerminalSessionLabel("term-1", summary)).toBe("bun"); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("falls back to getTerminalLabel when summary is missing or blank", () => { | ||
| 28 | expect(resolveTerminalSessionLabel(DEFAULT_TERMINAL_ID, { label: " " })).toBe("Terminal 1"); | ||
| 29 | expect(resolveTerminalSessionLabel(DEFAULT_TERMINAL_ID, null)).toBe("Terminal 1"); | ||
| 30 | expect(resolveTerminalSessionLabel("term-2", undefined)).toBe("Terminal 2"); | ||
| 31 | }); | ||
| 32 | }); | ||
| 33 | |||
| 34 | describe("nextTerminalId", () => { | ||
| 35 | it("allocates term-1 when no terminals are listed yet", () => { | ||
| 36 | expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID); | ||
| 37 | expect(nextTerminalId([])).toBe("term-1"); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("allocates term-2 when only term-1 exists", () => { | ||
| 41 | expect(nextTerminalId([DEFAULT_TERMINAL_ID])).toBe("term-2"); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("skips over taken term-N slots", () => { | ||
| 45 | expect(nextTerminalId([DEFAULT_TERMINAL_ID, "term-2", "term-3"])).toBe("term-4"); | ||
| 46 | expect(nextTerminalId([DEFAULT_TERMINAL_ID, "term-3"])).toBe("term-2"); | ||
| 47 | expect(nextTerminalId(["term-2", "term-3"])).toBe("term-1"); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("ignores blank/whitespace-only ids", () => { | ||
| 51 | expect(nextTerminalId(["", " ", DEFAULT_TERMINAL_ID])).toBe("term-2"); | ||
| 52 | expect(nextTerminalId(["", " "])).toBe("term-1"); | ||
| 53 | }); | ||
| 54 | }); | ||
packages/shared/src/terminalLabels.ts created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | import type { TerminalSummary } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | /** Human-readable label for a terminal tab; matches mobile and web sidebars. */ | ||
| 4 | export function getTerminalLabel(terminalId: string): string { | ||
| 5 | const numericSuffix = /^term(?:inal)?-(\d+)$/i.exec(terminalId)?.[1]; | ||
| 6 | if (numericSuffix) { | ||
| 7 | return `Terminal ${numericSuffix}`; | ||
| 8 | } | ||
| 9 | |||
| 10 | return terminalId; | ||
| 11 | } | ||
| 12 | |||
| 13 | /** Prefer server summary label when present; otherwise fall back to `getTerminalLabel`. */ | ||
| 14 | export function resolveTerminalSessionLabel( | ||
| 15 | terminalId: string, | ||
| 16 | summary: Pick<TerminalSummary, "label"> | null | undefined, | ||
| 17 | ): string { | ||
| 18 | const trimmed = summary?.label?.trim(); | ||
| 19 | if (trimmed && trimmed.length > 0) { | ||
| 20 | return trimmed; | ||
| 21 | } | ||
| 22 | return getTerminalLabel(terminalId); | ||
| 23 | } | ||
| 24 | |||
| 25 | /** | ||
| 26 | * Client-side terminal id allocator. Ids are ALWAYS chosen by the client and sent explicitly | ||
| 27 | * on every `terminal.open` / `terminal.attach` call — the server never allocates. | ||
| 28 | * | ||
| 29 | * Returns the lowest unused `term-N` id (starting at `term-1`), skipping any ids already in | ||
| 30 | * `existingTerminalIds`. | ||
| 31 | */ | ||
| 32 | export function nextTerminalId(existingTerminalIds: ReadonlyArray<string>): string { | ||
| 33 | const usedIds = new Set(existingTerminalIds.filter((id) => id.trim().length > 0)); | ||
| 34 | let nextIndex = 1; | ||
| 35 | while (usedIds.has(`term-${nextIndex}`)) { | ||
| 36 | nextIndex += 1; | ||
| 37 | } | ||
| 38 | |||
| 39 | return `term-${nextIndex}`; | ||
| 40 | } | ||
patches/@pierre%2Fdiffs@1.1.20.patch created+23| ... | @@ -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": { | ||
patches/react-native-nitro-modules@0.35.4.patch created+16| ... | @@ -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": { | ||
scripts/mobile-native-static-check.ts created+208| ... | @@ -0,0 +1,208 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | |||
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import * as Console from "effect/Console"; | ||
| 6 | import * as Data from "effect/Data"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as FileSystem from "effect/FileSystem"; | ||
| 9 | import * as Logger from "effect/Logger"; | ||
| 10 | import * as Path from "effect/Path"; | ||
| 11 | import * as PlatformError from "effect/PlatformError"; | ||
| 12 | import { Command } from "effect/unstable/cli"; | ||
| 13 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 14 | |||
| 15 | interface NativeStaticTool { | ||
| 16 | readonly command: string; | ||
| 17 | readonly installHint: string; | ||
| 18 | } | ||
| 19 | |||
| 20 | class NativeStaticCheckError extends Data.TaggedError("NativeStaticCheckError")<{ | ||
| 21 | readonly message: string; | ||
| 22 | }> {} | ||
| 23 | |||
| 24 | const tools = [ | ||
| 25 | { | ||
| 26 | command: "swiftlint", | ||
| 27 | installHint: "brew install swiftlint", | ||
| 28 | }, | ||
| 29 | { | ||
| 30 | command: "ktlint", | ||
| 31 | installHint: "brew install ktlint", | ||
| 32 | }, | ||
| 33 | { | ||
| 34 | command: "detekt", | ||
| 35 | installHint: "brew install detekt", | ||
| 36 | }, | ||
| 37 | ] as const satisfies ReadonlyArray<NativeStaticTool>; | ||
| 38 | |||
| 39 | const sourceExtensions = new Set([".swift", ".kt", ".kts"]); | ||
| 40 | const excludedDirectories = new Set([ | ||
| 41 | ".expo", | ||
| 42 | ".git", | ||
| 43 | "android", | ||
| 44 | "build", | ||
| 45 | "DerivedData", | ||
| 46 | "ios", | ||
| 47 | "node_modules", | ||
| 48 | "Pods", | ||
| 49 | "Vendor", | ||
| 50 | ]); | ||
| 51 | |||
| 52 | const appRoot = Effect.service(Path.Path).pipe( | ||
| 53 | Effect.flatMap((path) => path.fromFileUrl(new URL("../apps/mobile", import.meta.url))), | ||
| 54 | ); | ||
| 55 | |||
| 56 | const commandOutputOptions = { | ||
| 57 | stdout: "inherit", | ||
| 58 | stderr: "inherit", | ||
| 59 | } as const; | ||
| 60 | |||
| 61 | const commandExists = Effect.fn("commandExists")(function* (command: string) { | ||
| 62 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 63 | const lookupCommand = | ||
| 64 | process.platform === "win32" | ||
| 65 | ? ChildProcess.make("where", [command], { | ||
| 66 | stdout: "ignore", | ||
| 67 | stderr: "ignore", | ||
| 68 | }) | ||
| 69 | : ChildProcess.make("/bin/sh", ["-c", `command -v ${command}`], { | ||
| 70 | stdout: "ignore", | ||
| 71 | stderr: "ignore", | ||
| 72 | }); | ||
| 73 | |||
| 74 | return yield* spawner.spawn(lookupCommand).pipe( | ||
| 75 | Effect.flatMap((child) => child.exitCode), | ||
| 76 | Effect.map((exitCode) => exitCode === 0), | ||
| 77 | Effect.catch(() => Effect.succeed(false)), | ||
| 78 | ); | ||
| 79 | }); | ||
| 80 | |||
| 81 | const warnMissingTool = (tool: NativeStaticTool, checkName: string) => | ||
| 82 | Effect.logWarning( | ||
| 83 | `${tool.command} is not installed; skipping ${checkName}. Install it with '${tool.installHint}' or run 'brew bundle install --file apps/mobile/Brewfile'.`, | ||
| 84 | ); | ||
| 85 | |||
| 86 | const runCommand = Effect.fn("runCommand")(function* ( | ||
| 87 | command: string, | ||
| 88 | args: ReadonlyArray<string>, | ||
| 89 | cwd: string, | ||
| 90 | ) { | ||
| 91 | yield* Console.log(`$ ${[command, ...args].join(" ")}`); | ||
| 92 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 93 | const child = yield* spawner.spawn( | ||
| 94 | ChildProcess.make(command, [...args], { | ||
| 95 | cwd, | ||
| 96 | ...commandOutputOptions, | ||
| 97 | shell: process.platform === "win32", | ||
| 98 | }), | ||
| 99 | ); | ||
| 100 | const exitCode = Number(yield* child.exitCode); | ||
| 101 | |||
| 102 | if (exitCode !== 0) { | ||
| 103 | return yield* new NativeStaticCheckError({ | ||
| 104 | message: `Command exited with non-zero exit code (${exitCode})`, | ||
| 105 | }); | ||
| 106 | } | ||
| 107 | }); | ||
| 108 | |||
| 109 | function collectSources( | ||
| 110 | directory: string, | ||
| 111 | ): Effect.Effect< | ||
| 112 | ReadonlyArray<string>, | ||
| 113 | PlatformError.PlatformError, | ||
| 114 | FileSystem.FileSystem | Path.Path | ||
| 115 | > { | ||
| 116 | return Effect.gen(function* () { | ||
| 117 | const fs = yield* FileSystem.FileSystem; | ||
| 118 | const path = yield* Path.Path; | ||
| 119 | const entries = yield* fs.readDirectory(directory); | ||
| 120 | const sources: Array<string> = []; | ||
| 121 | |||
| 122 | for (const entry of entries) { | ||
| 123 | if (excludedDirectories.has(entry)) { | ||
| 124 | continue; | ||
| 125 | } | ||
| 126 | |||
| 127 | const entryPath = path.join(directory, entry); | ||
| 128 | const stat = yield* fs.stat(entryPath); | ||
| 129 | |||
| 130 | if (stat.type === "Directory") { | ||
| 131 | sources.push(...(yield* collectSources(entryPath))); | ||
| 132 | continue; | ||
| 133 | } | ||
| 134 | |||
| 135 | if (stat.type === "File" && sourceExtensions.has(path.extname(entry))) { | ||
| 136 | sources.push(entryPath); | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | return sources; | ||
| 141 | }); | ||
| 142 | } | ||
| 143 | |||
| 144 | const runNativeStaticChecks = Effect.fn("runNativeStaticChecks")(function* () { | ||
| 145 | const path = yield* Path.Path; | ||
| 146 | const root = yield* appRoot; | ||
| 147 | const sources = yield* collectSources(root); | ||
| 148 | const swiftSources = sources.filter((source) => path.extname(source) === ".swift"); | ||
| 149 | const kotlinSources = sources.filter((source) => { | ||
| 150 | const extension = path.extname(source); | ||
| 151 | return extension === ".kt" || extension === ".kts"; | ||
| 152 | }); | ||
| 153 | const availableTools = new Map<string, boolean>(); | ||
| 154 | |||
| 155 | for (const tool of tools) { | ||
| 156 | availableTools.set(tool.command, yield* commandExists(tool.command)); | ||
| 157 | } | ||
| 158 | |||
| 159 | if (swiftSources.length > 0) { | ||
| 160 | if (availableTools.get("swiftlint")) { | ||
| 161 | yield* runCommand("swiftlint", ["lint", "--config", ".swiftlint.yml", "--strict"], root); | ||
| 162 | } else { | ||
| 163 | yield* warnMissingTool(tools[0], "SwiftLint"); | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | if (kotlinSources.length > 0) { | ||
| 168 | const relativeKotlinSources = kotlinSources.map((source) => path.relative(root, source)); | ||
| 169 | |||
| 170 | if (availableTools.get("ktlint")) { | ||
| 171 | yield* runCommand("ktlint", relativeKotlinSources, root); | ||
| 172 | } else { | ||
| 173 | yield* warnMissingTool(tools[1], "ktlint"); | ||
| 174 | } | ||
| 175 | |||
| 176 | if (availableTools.get("detekt")) { | ||
| 177 | yield* runCommand( | ||
| 178 | "detekt", | ||
| 179 | [ | ||
| 180 | "--config", | ||
| 181 | "detekt.yml", | ||
| 182 | "--input", | ||
| 183 | relativeKotlinSources.join(","), | ||
| 184 | "--build-upon-default-config", | ||
| 185 | ], | ||
| 186 | root, | ||
| 187 | ); | ||
| 188 | } else { | ||
| 189 | yield* warnMissingTool(tools[2], "detekt"); | ||
| 190 | } | ||
| 191 | } | ||
| 192 | |||
| 193 | yield* Console.log("Skipping generated native project folders: android/, ios/."); | ||
| 194 | }); | ||
| 195 | |||
| 196 | export const mobileNativeStaticCheckCommand = Command.make("mobile-native-static-check", {}, () => | ||
| 197 | runNativeStaticChecks(), | ||
| 198 | ).pipe( | ||
| 199 | Command.withDescription("Run mobile native static analysis when native tools are available."), | ||
| 200 | ); | ||
| 201 | |||
| 202 | if (import.meta.main) { | ||
| 203 | Command.run(mobileNativeStaticCheckCommand, { version: "0.0.0" }).pipe( | ||
| 204 | Effect.scoped, | ||
| 205 | Effect.provide([Logger.layer([Logger.consolePretty()]), NodeServices.layer]), | ||
| 206 | NodeRuntime.runMain, | ||
| 207 | ); | ||
| 208 | } | ||