mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d96ee1522 | ||
|
|
aa50151fa1 | ||
|
|
bf4979fd62 |
Generated
+11
@@ -21273,6 +21273,16 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-screen-orientation": {
|
||||||
|
"version": "9.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-screen-orientation/-/expo-screen-orientation-9.0.9.tgz",
|
||||||
|
"integrity": "sha512-UTU745XoqBvQJkZ820GTfMuOxlkppYqaeQ+x0wdu44cyrZiZugqe+egFF2+zC+SaxgAltU5EuO8GIj9xSF+YMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-server": {
|
"node_modules/expo-server": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
||||||
@@ -36473,6 +36483,7 @@
|
|||||||
"expo-localization": "~17.0.9",
|
"expo-localization": "~17.0.9",
|
||||||
"expo-notifications": "^0.32.16",
|
"expo-notifications": "^0.32.16",
|
||||||
"expo-router": "~6.0.13",
|
"expo-router": "~6.0.13",
|
||||||
|
"expo-screen-orientation": "~9.0.9",
|
||||||
"expo-sharing": "^14.0.8",
|
"expo-sharing": "^14.0.8",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~16.0.10",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const path = require("node:path");
|
|||||||
const pkg = require("./package.json");
|
const pkg = require("./package.json");
|
||||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||||
|
const withAndroidFoldConfiguration = require("./plugins/with-android-fold-configuration");
|
||||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||||
const withPasteInput = require("./plugins/with-paste-input");
|
const withPasteInput = require("./plugins/with-paste-input");
|
||||||
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
|
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
|
||||||
@@ -100,7 +101,8 @@ export default {
|
|||||||
name: variant.name,
|
name: variant.name,
|
||||||
slug: "voice-mobile",
|
slug: "voice-mobile",
|
||||||
version: nativeReleaseVersion.appVersion,
|
version: nativeReleaseVersion.appVersion,
|
||||||
orientation: "portrait",
|
// Android chooses its full window before the runtime phone/fold/tablet policy applies.
|
||||||
|
orientation: "default",
|
||||||
icon: "./assets/images/icon.png",
|
icon: "./assets/images/icon.png",
|
||||||
scheme: "paseo",
|
scheme: "paseo",
|
||||||
userInterfaceStyle: "automatic",
|
userInterfaceStyle: "automatic",
|
||||||
@@ -108,6 +110,12 @@ export default {
|
|||||||
ios: {
|
ios: {
|
||||||
supportsTablet: true,
|
supportsTablet: true,
|
||||||
infoPlist: {
|
infoPlist: {
|
||||||
|
// Keep the existing iPhone restriction while the iPad orientation key
|
||||||
|
// generated by Expo continues to permit rotation.
|
||||||
|
UISupportedInterfaceOrientations: [
|
||||||
|
"UIInterfaceOrientationPortrait",
|
||||||
|
"UIInterfaceOrientationPortraitUpsideDown",
|
||||||
|
],
|
||||||
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
|
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
|
||||||
ITSAppUsesNonExemptEncryption: false,
|
ITSAppUsesNonExemptEncryption: false,
|
||||||
},
|
},
|
||||||
@@ -142,6 +150,7 @@ export default {
|
|||||||
plugins: [
|
plugins: [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
withPasteInput,
|
withPasteInput,
|
||||||
|
withAndroidFoldConfiguration,
|
||||||
withAndroidScroll,
|
withAndroidScroll,
|
||||||
[withAndroidAsyncStorageSize, 64],
|
[withAndroidAsyncStorageSize, 64],
|
||||||
...buildProfile.cameraPlugins,
|
...buildProfile.cameraPlugins,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Run only on a task-owned foldable emulator with a connected Paseo debug app.
|
||||||
|
# Usage: native-fold-history-route.android.sh <adb-serial> <avd-name> <agent-device-session> <workspace-name>
|
||||||
|
serial=${1:?adb serial required}
|
||||||
|
avd_name=${2:?expected AVD name required}
|
||||||
|
session=${3:?agent-device session required}
|
||||||
|
workspace_name=${4:?workspace name required}
|
||||||
|
|
||||||
|
actual_avd=$(adb -s "$serial" shell getprop ro.boot.qemu.avd_name | tr -d '\r')
|
||||||
|
if [[ "$actual_avd" != "$avd_name" ]]; then
|
||||||
|
echo "Refusing device $serial: expected $avd_name, found $actual_avd" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
initial_state=$(adb -s "$serial" shell cmd device_state print-state | tr -d '\r')
|
||||||
|
if [[ "$initial_state" != "0" && "$initial_state" != "2" ]]; then
|
||||||
|
echo "Expected CLOSED=0 or OPENED=2; got $initial_state" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
trap 'adb -s "$serial" shell cmd device_state state "$initial_state" >/dev/null' EXIT
|
||||||
|
|
||||||
|
wait_for_history() {
|
||||||
|
local snapshot
|
||||||
|
for _ in {1..20}; do
|
||||||
|
snapshot=$(agent-device snapshot --session "$session" -i)
|
||||||
|
if rg -q '\[group\] "History"' <<<"$snapshot"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
echo "$snapshot" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_compact_workspace() {
|
||||||
|
local snapshot
|
||||||
|
for _ in {1..20}; do
|
||||||
|
snapshot=$(agent-device snapshot --session "$session" -i)
|
||||||
|
if rg -q 'Unable to find node on an unmounted component|RootErrorBoundary' <<<"$snapshot"; then
|
||||||
|
echo "Workspace hit a React Native error after folding" >&2
|
||||||
|
echo "$snapshot" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if rg -q 'Switch tabs \(' <<<"$snapshot"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
echo "$snapshot" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
adb -s "$serial" shell cmd device_state state 2
|
||||||
|
sleep 3
|
||||||
|
agent-device press 'text="History"' --session "$session"
|
||||||
|
if ! wait_for_history; then
|
||||||
|
echo "History route did not open on the wide foldable" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
adb -s "$serial" shell cmd device_state state 0
|
||||||
|
sleep 4
|
||||||
|
if ! wait_for_history; then
|
||||||
|
echo "History route was lost after folding CLOSED" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
adb -s "$serial" shell cmd device_state state 2
|
||||||
|
sleep 4
|
||||||
|
if ! wait_for_history; then
|
||||||
|
echo "History route was lost after unfolding OPENED" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
agent-device press "label=\"$workspace_name\"" --session "$session"
|
||||||
|
adb -s "$serial" shell cmd device_state state 0
|
||||||
|
sleep 4
|
||||||
|
if ! wait_for_compact_workspace; then
|
||||||
|
echo "Compact workspace failed after returning from History" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "History retained and compact workspace mounted after OPENED -> CLOSED -> OPENED -> CLOSED"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Android fold-open flow
|
||||||
|
|
||||||
|
Use a task-owned foldable emulator with real `cmd device_state` CLOSED (0) and OPENED (2) states, a connected development app, and an unsent draft. The recorded run used `paseo-fold-p3a` (Pixel 9 Pro Fold, API 35). Set `DRAFT_TEXT` to the exact composer value. Run the flow after a CLOSED → OPENED transition and confirm `adb -s <serial> shell cmd device_state print-state` returns `2`. `workspace-tabs-scroll` and the Settings footer prove the wide shell is usable. A regular tablet can satisfy those UI assertions, so the device-state precondition is part of the test, not inferred from the flow.
|
||||||
|
|
||||||
|
The `.ad` flow opens the app to establish an agent-device session. For the Gboard transition check, keep the app foreground, show Gboard with a real unsent draft, change device state with `cmd device_state state 0` then `state 2`, and capture the app before reopening it. An app-open step can recover a missed native layout callback and cannot prove that the immediate transition was correct. Retain before/after screenshots, a post-transition accessibility snapshot, `dumpsys activity` window bounds, and `dumpsys input_method` IME state. Repeat with the keyboard hidden, and verify CLOSED still presents the compact tab switcher.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
context platform=android timeout=60000 retries=0
|
||||||
|
env APP_ID=sh.paseo.debug
|
||||||
|
|
||||||
|
open "${APP_ID}"
|
||||||
|
wait "id=\"workspace-tabs-scroll\"" 12000
|
||||||
|
wait "label=\"${DRAFT_TEXT}\"" 10000
|
||||||
|
wait "label=\"Settings\"" 10000
|
||||||
|
close
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
group = 'sh.paseo.screengeometry'
|
||||||
|
version = '0.1.0'
|
||||||
|
|
||||||
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
useCoreDependencies()
|
||||||
|
useExpoPublishing()
|
||||||
|
useDefaultAndroidSdkVersions()
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "sh.paseo.screengeometry"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package sh.paseo.screengeometry
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import expo.modules.kotlin.exception.Exceptions
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class PaseoScreenGeometryModule : Module() {
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("PaseoScreenGeometry")
|
||||||
|
|
||||||
|
Function("getPhysicalScreenSize") {
|
||||||
|
val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
|
||||||
|
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
activity.display
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
activity.windowManager.defaultDisplay
|
||||||
|
}
|
||||||
|
val mode = display.mode
|
||||||
|
val density = activity.resources.displayMetrics.density
|
||||||
|
mapOf(
|
||||||
|
"width" to mode.physicalWidth / density,
|
||||||
|
"height" to mode.physicalHeight / density,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["android"],
|
||||||
|
"android": {
|
||||||
|
"modules": ["sh.paseo.screengeometry.PaseoScreenGeometryModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "paseo-screen-geometry",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -95,6 +95,7 @@
|
|||||||
"expo-localization": "~17.0.9",
|
"expo-localization": "~17.0.9",
|
||||||
"expo-notifications": "^0.32.16",
|
"expo-notifications": "^0.32.16",
|
||||||
"expo-router": "~6.0.13",
|
"expo-router": "~6.0.13",
|
||||||
|
"expo-screen-orientation": "~9.0.9",
|
||||||
"expo-sharing": "^14.0.8",
|
"expo-sharing": "^14.0.8",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-sqlite": "~16.0.10",
|
"expo-sqlite": "~16.0.10",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const { withAndroidManifest } = require("expo/config-plugins");
|
||||||
|
|
||||||
|
function withAndroidFoldConfiguration(config) {
|
||||||
|
return withAndroidManifest(config, (modConfig) => {
|
||||||
|
const activity = modConfig.modResults.manifest.application?.[0]?.activity?.find(
|
||||||
|
(entry) => entry.$?.["android:name"] === ".MainActivity",
|
||||||
|
);
|
||||||
|
if (!activity) {
|
||||||
|
throw new Error("Could not configure fold changes without MainActivity");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fold changes smallestScreenWidthDp as well as screenSize. Let React Native and
|
||||||
|
// Unistyles receive the configuration change without Android recreating MainActivity.
|
||||||
|
const changes = new Set((activity.$["android:configChanges"] ?? "").split("|").filter(Boolean));
|
||||||
|
changes.add("smallestScreenSize");
|
||||||
|
activity.$["android:configChanges"] = [...changes].join("|");
|
||||||
|
return modConfig;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withAndroidFoldConfiguration;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { getConfig } from "@expo/config";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("native orientation configuration", () => {
|
||||||
|
const { exp } = getConfig(path.resolve(import.meta.dirname, ".."));
|
||||||
|
|
||||||
|
it("lets Android choose the full display window before runtime orientation policy applies", () => {
|
||||||
|
expect(exp.orientation).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the iPhone portrait orientations explicit while iPad supports rotation", () => {
|
||||||
|
expect(exp.ios?.infoPlist?.UISupportedInterfaceOrientations).toEqual([
|
||||||
|
"UIInterfaceOrientationPortrait",
|
||||||
|
"UIInterfaceOrientationPortraitUpsideDown",
|
||||||
|
]);
|
||||||
|
expect(exp.ios?.supportsTablet).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { AppState, useWindowDimensions, View } from "react-native";
|
import { AppState, View, type LayoutChangeEvent } from "react-native";
|
||||||
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
useIsCompactFormFactor,
|
useIsCompactFormFactor,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
|
import { MeasuredWindowWidthProvider, useLayoutWindowWidth } from "@/constants/window-width";
|
||||||
import {
|
import {
|
||||||
canDesktopAppSidebarShare,
|
canDesktopAppSidebarShare,
|
||||||
resolveDesktopAppChromeLayout,
|
resolveDesktopAppChromeLayout,
|
||||||
@@ -87,6 +88,7 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
|
|||||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { useAdaptiveOrientation } from "@/orientation";
|
||||||
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
|
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
|
||||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||||
@@ -457,6 +459,7 @@ interface AppContainerProps {
|
|||||||
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
|
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
|
||||||
|
|
||||||
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
||||||
|
const isOrientationReady = useAdaptiveOrientation();
|
||||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
@@ -466,7 +469,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const { width: viewportWidth } = useWindowDimensions();
|
const viewportWidth = useLayoutWindowWidth();
|
||||||
|
|
||||||
const cycleTheme = useCallback(() => {
|
const cycleTheme = useCallback(() => {
|
||||||
void updateSettings({ theme: getNextThemePreference(settings.theme) });
|
void updateSettings({ theme: getNextThemePreference(settings.theme) });
|
||||||
@@ -622,6 +625,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
surface
|
surface
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isOrientationReady) return null;
|
||||||
|
|
||||||
return <CommandCenterProvider>{content}</CommandCenterProvider>;
|
return <CommandCenterProvider>{content}</CommandCenterProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,16 +972,23 @@ function RootProviders({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RootAppTree() {
|
function RootAppTree() {
|
||||||
|
const [measuredWindowWidth, setMeasuredWindowWidth] = useState<number | null>(null);
|
||||||
|
const handleRootLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
const width = event.nativeEvent.layout.width;
|
||||||
|
if (width > 0) setMeasuredWindowWidth((current) => (current === width ? current : width));
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={flexStyle}>
|
<MeasuredWindowWidthProvider width={measuredWindowWidth}>
|
||||||
<View style={layoutStyles.surfaceFill}>
|
<GestureHandlerRootView style={flexStyle} onLayout={handleRootLayout}>
|
||||||
<RootProviders>
|
<View style={layoutStyles.surfaceFill}>
|
||||||
<RuntimeProviders>
|
<RootProviders>
|
||||||
<AppShell />
|
<RuntimeProviders>
|
||||||
</RuntimeProviders>
|
<AppShell />
|
||||||
</RootProviders>
|
</RuntimeProviders>
|
||||||
</View>
|
</RootProviders>
|
||||||
</GestureHandlerRootView>
|
</View>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</MeasuredWindowWidthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet as RNStyleSheet,
|
StyleSheet as RNStyleSheet,
|
||||||
Text,
|
Text,
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
View,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -29,6 +28,7 @@ import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
|||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { useLayoutWindowWidth } from "@/constants/window-width";
|
||||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||||
import { useImportSession } from "@/hooks/use-import-session";
|
import { useImportSession } from "@/hooks/use-import-session";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
@@ -678,7 +678,7 @@ function DesktopSidebar({
|
|||||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
const { width: viewportWidth } = useWindowDimensions();
|
const viewportWidth = useLayoutWindowWidth();
|
||||||
const visibleSidebarWidth = resolveDesktopSidebarWidth({
|
const visibleSidebarWidth = resolveDesktopSidebarWidth({
|
||||||
requestedWidth: sidebarWidth,
|
requestedWidth: sidebarWidth,
|
||||||
viewportWidth,
|
viewportWidth,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveLayoutWindowWidth } from "./window-width";
|
||||||
|
|
||||||
|
describe("resolveLayoutWindowWidth", () => {
|
||||||
|
it("uses the rendered root after a fold when RN Dimensions still reports the closed width", () => {
|
||||||
|
expect(resolveLayoutWindowWidth(851.7, 443.1)).toBe(851.7);
|
||||||
|
expect(resolveLayoutWindowWidth(443.1, 851.7)).toBe(443.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses RN's initial width until the root has laid out", () => {
|
||||||
|
expect(resolveLayoutWindowWidth(null, 411)).toBe(411);
|
||||||
|
expect(resolveLayoutWindowWidth(0, 411)).toBe(411);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import { useWindowDimensions } from "react-native";
|
||||||
|
|
||||||
|
const MeasuredWindowWidthContext = createContext<number | null>(null);
|
||||||
|
|
||||||
|
export function resolveLayoutWindowWidth(measuredWidth: number | null, dimensionsWidth: number) {
|
||||||
|
return measuredWidth !== null && measuredWidth > 0 ? measuredWidth : dimensionsWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeasuredWindowWidthProvider({
|
||||||
|
width,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
width: number | null;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MeasuredWindowWidthContext.Provider value={width}>
|
||||||
|
{children}
|
||||||
|
</MeasuredWindowWidthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of the rendered root view; RN Dimensions until the first layout.
|
||||||
|
* RN Dimensions settles after the root layout during an in-place Android fold.
|
||||||
|
* Sizing panes from Dimensions alone made the compact panel gesture fail to
|
||||||
|
* attach after folding closed, so pane sizing reads this width instead.
|
||||||
|
*/
|
||||||
|
export function useLayoutWindowWidth(): number {
|
||||||
|
const measuredWidth = useContext(MeasuredWindowWidthContext);
|
||||||
|
const { width: dimensionsWidth } = useWindowDimensions();
|
||||||
|
return resolveLayoutWindowWidth(measuredWidth, dimensionsWidth);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Dimensions } from "react-native";
|
||||||
|
import { useLayoutWindowWidth } from "@/constants/window-width";
|
||||||
|
import { getAndroidPhysicalScreenSize } from "./screen-geometry";
|
||||||
|
import { resolveAndroidOrientationPolicy } from "./policy";
|
||||||
|
|
||||||
|
export function useAdaptiveOrientation(): boolean {
|
||||||
|
const layoutWindowWidth = useLayoutWindowWidth();
|
||||||
|
const [screen, setScreen] = useState(getAndroidPhysicalScreenSize);
|
||||||
|
const policy = resolveAndroidOrientationPolicy(screen);
|
||||||
|
const [ready, setReady] = useState(() => policy === "system");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateScreen = () => {
|
||||||
|
const nextScreen = getAndroidPhysicalScreenSize();
|
||||||
|
setScreen((current) =>
|
||||||
|
current.width === nextScreen.width && current.height === nextScreen.height
|
||||||
|
? current
|
||||||
|
: nextScreen,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
updateScreen();
|
||||||
|
const subscription = Dimensions.addEventListener("change", updateScreen);
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [layoutWindowWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const request =
|
||||||
|
policy === "portrait"
|
||||||
|
? ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
|
: ScreenOrientation.unlockAsync();
|
||||||
|
|
||||||
|
void request
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("[Orientation] Could not apply Android screen orientation:", error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setReady(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [policy]);
|
||||||
|
|
||||||
|
// An unrestricted native cold start can briefly report a landscape phone
|
||||||
|
// window; wait for the portrait request before mounting layout content.
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function useAdaptiveOrientation(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveAndroidOrientationPolicy } from "./policy";
|
||||||
|
|
||||||
|
describe("Android orientation policy", () => {
|
||||||
|
it("allows a landscape tablet display at the API 35 letterbox baseline", () => {
|
||||||
|
expect(resolveAndroidOrientationPolicy({ width: 1280, height: 800 })).toBe("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes with a real fold display transition in either direction", () => {
|
||||||
|
const unfolded = { width: 852, height: 883 };
|
||||||
|
const folded = { width: 443, height: 994 };
|
||||||
|
expect(resolveAndroidOrientationPolicy(unfolded)).toBe("system");
|
||||||
|
expect(resolveAndroidOrientationPolicy(folded)).toBe("portrait");
|
||||||
|
expect(resolveAndroidOrientationPolicy(unfolded)).toBe("system");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a phone portrait when physically turned landscape", () => {
|
||||||
|
expect(resolveAndroidOrientationPolicy({ width: 914, height: 411 })).toBe("portrait");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the 600dp shortest-side threshold regardless of rotation", () => {
|
||||||
|
expect(resolveAndroidOrientationPolicy({ width: 600, height: 1000 })).toBe("system");
|
||||||
|
expect(resolveAndroidOrientationPolicy({ width: 1000, height: 599 })).toBe("portrait");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export type AndroidOrientationPolicy = "portrait" | "system";
|
||||||
|
|
||||||
|
// Screen size owns the Android orientation allowance. Pane layout still uses
|
||||||
|
// the available window width, including in split and floating windows.
|
||||||
|
export function resolveAndroidOrientationPolicy(screen: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}): AndroidOrientationPolicy {
|
||||||
|
return Math.min(screen.width, screen.height) >= 600 ? "system" : "portrait";
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { requireNativeModule } from "expo-modules-core";
|
||||||
|
|
||||||
|
interface AndroidPhysicalScreenSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaseoScreenGeometryModule {
|
||||||
|
getPhysicalScreenSize(): AndroidPhysicalScreenSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAndroidPhysicalScreenSize(): AndroidPhysicalScreenSize {
|
||||||
|
return requireNativeModule<PaseoScreenGeometryModule>(
|
||||||
|
"PaseoScreenGeometry",
|
||||||
|
).getPhysicalScreenSize();
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useWindowDimensions } from "react-native";
|
import { useLayoutWindowWidth } from "@/constants/window-width";
|
||||||
import {
|
import {
|
||||||
computeWorkspaceTabLayout,
|
computeWorkspaceTabLayout,
|
||||||
type WorkspaceTabLayoutMetrics,
|
type WorkspaceTabLayoutMetrics,
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@/screens/workspace/workspace-tab-layout";
|
} from "@/screens/workspace/workspace-tab-layout";
|
||||||
|
|
||||||
interface UseWorkspaceTabLayoutInput {
|
interface UseWorkspaceTabLayoutInput {
|
||||||
tabLabelWidths: number[];
|
tabLabelWidths: (number | null)[];
|
||||||
viewportWidthOverride?: number | null;
|
viewportWidthOverride?: number | null;
|
||||||
metrics: WorkspaceTabLayoutMetrics;
|
metrics: WorkspaceTabLayoutMetrics;
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ interface UseWorkspaceTabLayoutResult {
|
|||||||
export function useWorkspaceTabLayout(
|
export function useWorkspaceTabLayout(
|
||||||
input: UseWorkspaceTabLayoutInput,
|
input: UseWorkspaceTabLayoutInput,
|
||||||
): UseWorkspaceTabLayoutResult {
|
): UseWorkspaceTabLayoutResult {
|
||||||
const { width: viewportWidth } = useWindowDimensions();
|
const viewportWidth = useLayoutWindowWidth();
|
||||||
const resolvedViewportWidth =
|
const resolvedViewportWidth =
|
||||||
typeof input.viewportWidthOverride === "number" && input.viewportWidthOverride > 0
|
typeof input.viewportWidthOverride === "number" && input.viewportWidthOverride > 0
|
||||||
? input.viewportWidthOverride
|
? input.viewportWidthOverride
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import React, {
|
|||||||
type Dispatch,
|
type Dispatch,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Pressable, Text, View, type LayoutChangeEvent } from "react-native";
|
import { Pressable, Text, View, type LayoutChangeEvent, type ScrollView } from "react-native";
|
||||||
import {
|
import {
|
||||||
CopyX,
|
CopyX,
|
||||||
ArrowLeftToLine,
|
ArrowLeftToLine,
|
||||||
@@ -49,7 +49,10 @@ import { buttonControlHeight } from "@/components/ui/control-geometry";
|
|||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||||
import { retainWorkspaceTabMeasuredWidth } from "@/screens/workspace/workspace-tab-layout";
|
import {
|
||||||
|
retainWorkspaceTabMeasuredWidth,
|
||||||
|
scrollOffsetToRevealWorkspaceTab,
|
||||||
|
} from "@/screens/workspace/workspace-tab-layout";
|
||||||
import {
|
import {
|
||||||
WorkspaceTabPresentationResolver,
|
WorkspaceTabPresentationResolver,
|
||||||
WorkspaceTabIcon,
|
WorkspaceTabIcon,
|
||||||
@@ -459,39 +462,25 @@ interface WorkspaceTabLabelMeasurement {
|
|||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceTabTrackSnapshot {
|
function resolveWorkspaceTabLabelWidths(
|
||||||
signature: string;
|
|
||||||
tabs: ResolvedWorkspaceDesktopTabRowItem[];
|
|
||||||
labels: WorkspaceTabLabel[];
|
|
||||||
labelWidths: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspaceTabLabelSignature(labels: WorkspaceTabLabel[]): string {
|
|
||||||
return JSON.stringify(labels);
|
|
||||||
}
|
|
||||||
|
|
||||||
function completeWorkspaceTabLabelWidths(
|
|
||||||
labels: WorkspaceTabLabel[],
|
labels: WorkspaceTabLabel[],
|
||||||
measurements: Map<string, WorkspaceTabLabelMeasurement>,
|
measurements: Map<string, WorkspaceTabLabelMeasurement>,
|
||||||
): number[] | null {
|
): (number | null)[] {
|
||||||
const widths: number[] = [];
|
const widths: (number | null)[] = [];
|
||||||
for (const { key, label, modified } of labels) {
|
for (const { key, label, modified } of labels) {
|
||||||
const measurement = measurements.get(key);
|
const measurement = measurements.get(key);
|
||||||
if (!measurement || measurement.label !== label || measurement.width <= 0) {
|
const labelWidth =
|
||||||
return null;
|
measurement?.label === label && measurement.width > 0 ? measurement.width : null;
|
||||||
}
|
|
||||||
// The modified dot sits in the content row, so a modified tab needs that much more width
|
// The modified dot sits in the content row, so a modified tab needs that much more width
|
||||||
// before its label starts truncating.
|
// before its label starts truncating.
|
||||||
const modifiedAllowance = modified ? TAB_CONTENT_GAP + TAB_MODIFIED_DOT_SIZE : 0;
|
const modifiedAllowance = modified ? TAB_CONTENT_GAP + TAB_MODIFIED_DOT_SIZE : 0;
|
||||||
widths.push(measurement.width + TAB_LABEL_LAYOUT_ALLOWANCE + modifiedAllowance);
|
widths.push(
|
||||||
|
labelWidth === null ? null : labelWidth + TAB_LABEL_LAYOUT_ALLOWANCE + modifiedAllowance,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return widths;
|
return widths;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameWidths(left: number[], right: number[]): boolean {
|
|
||||||
return left.length === right.length && left.every((width, index) => width === right[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkspaceDesktopTabsRowProps {
|
interface WorkspaceDesktopTabsRowProps {
|
||||||
paneId?: string;
|
paneId?: string;
|
||||||
isFocused?: boolean;
|
isFocused?: boolean;
|
||||||
@@ -536,8 +525,6 @@ interface WorkspaceDesktopTabPresentationSlotProps {
|
|||||||
onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
|
onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_RESOLVED_TAB_ROWS: ResolvedWorkspaceDesktopTabRowItem[] = [];
|
|
||||||
|
|
||||||
function WorkspaceDesktopTabPresentationSlot({
|
function WorkspaceDesktopTabPresentationSlot({
|
||||||
tab,
|
tab,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -1022,12 +1009,14 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
||||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||||
|
const [tabsScrollViewportWidth, setTabsScrollViewportWidth] = useState<number>(0);
|
||||||
|
const tabsScrollRef = useRef<ScrollView>(null);
|
||||||
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
|
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
|
||||||
const tabScrollBoundary = useHorizontalScrollBoundary();
|
const tabScrollBoundary = useHorizontalScrollBoundary();
|
||||||
|
const { onLayout: onTabBoundaryLayout } = tabScrollBoundary;
|
||||||
const [labelMeasurements, setLabelMeasurements] = useState(
|
const [labelMeasurements, setLabelMeasurements] = useState(
|
||||||
() => new Map<string, WorkspaceTabLabelMeasurement>(),
|
() => new Map<string, WorkspaceTabLabelMeasurement>(),
|
||||||
);
|
);
|
||||||
const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(null);
|
|
||||||
|
|
||||||
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
updateMeasuredWidth(setTabsContainerWidth, event);
|
updateMeasuredWidth(setTabsContainerWidth, event);
|
||||||
@@ -1101,7 +1090,6 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
}),
|
}),
|
||||||
[fallbackTabLabels, tabs],
|
[fallbackTabLabels, tabs],
|
||||||
);
|
);
|
||||||
const tabLabelSignature = useMemo(() => workspaceTabLabelSignature(tabLabels), [tabLabels]);
|
|
||||||
const currentTabLabelKeys = useMemo(() => new Set(tabLabels.map(({ key }) => key)), [tabLabels]);
|
const currentTabLabelKeys = useMemo(() => new Set(tabLabels.map(({ key }) => key)), [tabLabels]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLabelMeasurements((current) => {
|
setLabelMeasurements((current) => {
|
||||||
@@ -1116,35 +1104,6 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [currentTabLabelKeys]);
|
}, [currentTabLabelKeys]);
|
||||||
const publishMeasuredTrack = useCallback(() => {
|
|
||||||
if (tabsContainerWidth <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const labelWidths = completeWorkspaceTabLabelWidths(tabLabels, labelMeasurements);
|
|
||||||
if (!labelWidths) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTrackSnapshot((current) => {
|
|
||||||
if (
|
|
||||||
current?.signature === tabLabelSignature &&
|
|
||||||
sameWidths(current.labelWidths, labelWidths)
|
|
||||||
) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
signature: tabLabelSignature,
|
|
||||||
tabs,
|
|
||||||
labels: tabLabels,
|
|
||||||
labelWidths,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [labelMeasurements, tabLabelSignature, tabLabels, tabs, tabsContainerWidth]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
publishMeasuredTrack();
|
|
||||||
}, [publishMeasuredTrack]);
|
|
||||||
|
|
||||||
const handleTabLabelLayout = useCallback(
|
const handleTabLabelLayout = useCallback(
|
||||||
(key: string, label: string, event: LayoutChangeEvent) => {
|
(key: string, label: string, event: LayoutChangeEvent) => {
|
||||||
const width = Math.ceil(event.nativeEvent.layout.width);
|
const width = Math.ceil(event.nativeEvent.layout.width);
|
||||||
@@ -1164,25 +1123,46 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const displayedTabs = useMemo(() => {
|
const tabLabelWidths = useMemo(
|
||||||
if (!trackSnapshot) {
|
() => resolveWorkspaceTabLabelWidths(tabLabels, labelMeasurements),
|
||||||
return EMPTY_RESOLVED_TAB_ROWS;
|
[tabLabels, labelMeasurements],
|
||||||
}
|
);
|
||||||
const currentTabs = new Map(
|
const displayedTabs = tabs;
|
||||||
tabs.map((tab, index) => [tab.tab.key, { tab, label: tabLabels[index]?.label }]),
|
|
||||||
);
|
|
||||||
return trackSnapshot.tabs.map((snapshotTab, index) => {
|
|
||||||
const current = currentTabs.get(snapshotTab.tab.key);
|
|
||||||
return current?.label === trackSnapshot.labels[index]?.label ? current.tab : snapshotTab;
|
|
||||||
});
|
|
||||||
}, [tabLabels, tabs, trackSnapshot]);
|
|
||||||
|
|
||||||
const { layout } = useWorkspaceTabLayout({
|
const { layout } = useWorkspaceTabLayout({
|
||||||
tabLabelWidths: trackSnapshot?.labelWidths ?? [],
|
tabLabelWidths,
|
||||||
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
|
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
|
||||||
metrics: layoutMetrics,
|
metrics: layoutMetrics,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedTabIndex = displayedTabs.findIndex((item) => item.isActive);
|
||||||
|
const selectedTabKey = selectedTabIndex >= 0 ? displayedTabs[selectedTabIndex]?.tab.key : null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!layout.requiresHorizontalScrollFallback || selectedTabKey === null) return;
|
||||||
|
const offset = scrollOffsetToRevealWorkspaceTab(
|
||||||
|
layout.items.map((item) => item.width),
|
||||||
|
selectedTabIndex,
|
||||||
|
tabsScrollViewportWidth,
|
||||||
|
TAB_CHIP_GAP,
|
||||||
|
TAB_ROW_PADDING_HORIZONTAL,
|
||||||
|
);
|
||||||
|
tabsScrollRef.current?.scrollTo({ x: offset, animated: true });
|
||||||
|
}, [
|
||||||
|
layout.items,
|
||||||
|
layout.requiresHorizontalScrollFallback,
|
||||||
|
selectedTabIndex,
|
||||||
|
selectedTabKey,
|
||||||
|
tabsScrollViewportWidth,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleTabsScrollLayout = useCallback(
|
||||||
|
(event: LayoutChangeEvent) => {
|
||||||
|
onTabBoundaryLayout(event);
|
||||||
|
updateMeasuredWidth(setTabsScrollViewportWidth, event);
|
||||||
|
},
|
||||||
|
[onTabBoundaryLayout],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => {
|
(nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => {
|
||||||
onReorderTabs(nextTabs.map((tab) => tab.tab));
|
onReorderTabs(nextTabs.map((tab) => tab.tab));
|
||||||
@@ -1336,13 +1316,14 @@ function ResolvedWorkspaceDesktopTabsRow({
|
|||||||
/>
|
/>
|
||||||
<View style={styles.tabsScrollContainer}>
|
<View style={styles.tabsScrollContainer}>
|
||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
|
ref={tabsScrollRef}
|
||||||
horizontal
|
horizontal
|
||||||
scrollEnabled={layout.requiresHorizontalScrollFallback}
|
scrollEnabled={layout.requiresHorizontalScrollFallback}
|
||||||
testID="workspace-tabs-scroll"
|
testID="workspace-tabs-scroll"
|
||||||
style={tabsScrollStyle}
|
style={tabsScrollStyle}
|
||||||
contentContainerStyle={styles.tabsContent}
|
contentContainerStyle={styles.tabsContent}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onLayout={tabScrollBoundary.onLayout}
|
onLayout={handleTabsScrollLayout}
|
||||||
onContentSizeChange={tabScrollBoundary.onContentSizeChange}
|
onContentSizeChange={tabScrollBoundary.onContentSizeChange}
|
||||||
onScroll={tabScrollBoundary.onScroll}
|
onScroll={tabScrollBoundary.onScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
computeWorkspaceTabLayout,
|
computeWorkspaceTabLayout,
|
||||||
retainWorkspaceTabMeasuredWidth,
|
retainWorkspaceTabMeasuredWidth,
|
||||||
|
scrollOffsetToRevealWorkspaceTab,
|
||||||
} from "@/screens/workspace/workspace-tab-layout";
|
} from "@/screens/workspace/workspace-tab-layout";
|
||||||
|
|
||||||
const metrics = {
|
const metrics = {
|
||||||
@@ -120,6 +121,33 @@ describe("computeWorkspaceTabLayout", () => {
|
|||||||
|
|
||||||
expect(result.items.map((item) => item.width)).toEqual([102, 138]);
|
expect(result.items.map((item) => item.width)).toEqual([102, 138]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps unmeasured tabs and controls accessible until text layout arrives", () => {
|
||||||
|
const initial = computeWorkspaceTabLayout({
|
||||||
|
viewportWidth: 440,
|
||||||
|
tabLabelWidths: [null, null, null],
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
expect(initial.items.map((item) => item.width)).toEqual([96, 96, 96]);
|
||||||
|
expect(initial.closeButtonPolicy).toBe("all");
|
||||||
|
expect(initial.requiresHorizontalScrollFallback).toBe(false);
|
||||||
|
|
||||||
|
const measured = computeWorkspaceTabLayout({
|
||||||
|
viewportWidth: 440,
|
||||||
|
tabLabelWidths: [40, 130, 90],
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
expect(measured.items).toHaveLength(3);
|
||||||
|
expect(measured.items[1]?.width).toBeGreaterThan(initial.items[1]?.width ?? 0);
|
||||||
|
|
||||||
|
const overflowing = computeWorkspaceTabLayout({
|
||||||
|
viewportWidth: 440,
|
||||||
|
tabLabelWidths: [null, null, null, null],
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
expect(overflowing.requiresHorizontalScrollFallback).toBe(true);
|
||||||
|
expect(overflowing.items).toHaveLength(4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("retainWorkspaceTabMeasuredWidth", () => {
|
describe("retainWorkspaceTabMeasuredWidth", () => {
|
||||||
@@ -131,3 +159,15 @@ describe("retainWorkspaceTabMeasuredWidth", () => {
|
|||||||
expect(retainWorkspaceTabMeasuredWidth(720, 640)).toBe(640);
|
expect(retainWorkspaceTabMeasuredWidth(720, 640)).toBe(640);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scrollOffsetToRevealWorkspaceTab", () => {
|
||||||
|
it("reveals the selected tab after a wide row becomes an overflowing row", () => {
|
||||||
|
const widths = Array.from({ length: 6 }, () => 96);
|
||||||
|
expect(scrollOffsetToRevealWorkspaceTab(widths, 5, 482, 4, 4)).toBe(122);
|
||||||
|
expect(scrollOffsetToRevealWorkspaceTab(widths, 0, 482, 4, 4)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a selected tab in place when it already fits", () => {
|
||||||
|
expect(scrollOffsetToRevealWorkspaceTab([96, 160, 96], 1, 480, 4, 4)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface WorkspaceTabLayoutMetrics {
|
|||||||
|
|
||||||
export interface WorkspaceTabLayoutInput {
|
export interface WorkspaceTabLayoutInput {
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
tabLabelWidths: number[];
|
tabLabelWidths: (number | null)[];
|
||||||
metrics: WorkspaceTabLayoutMetrics;
|
metrics: WorkspaceTabLayoutMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +40,21 @@ export function retainWorkspaceTabMeasuredWidth(
|
|||||||
return measuredWidth;
|
return measuredWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scrollOffsetToRevealWorkspaceTab(
|
||||||
|
widths: readonly number[],
|
||||||
|
selectedIndex: number,
|
||||||
|
viewportWidth: number,
|
||||||
|
gap: number,
|
||||||
|
padding: number,
|
||||||
|
): number {
|
||||||
|
if (selectedIndex < 0 || selectedIndex >= widths.length || viewportWidth <= 0) return 0;
|
||||||
|
const end =
|
||||||
|
padding +
|
||||||
|
widths.slice(0, selectedIndex + 1).reduce((total, width) => total + width, 0) +
|
||||||
|
gap * selectedIndex;
|
||||||
|
return Math.max(0, end + padding - viewportWidth);
|
||||||
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
if (value < min) {
|
if (value < min) {
|
||||||
return min;
|
return min;
|
||||||
@@ -74,8 +89,13 @@ export function computeWorkspaceTabLayout(
|
|||||||
input.metrics.tabContentGap +
|
input.metrics.tabContentGap +
|
||||||
input.metrics.tabHorizontalPadding * 2 +
|
input.metrics.tabHorizontalPadding * 2 +
|
||||||
input.metrics.closeButtonWidth;
|
input.metrics.closeButtonWidth;
|
||||||
|
// An unmeasured label starts at the chip's known minimum. The row stays
|
||||||
|
// interactive if native skips the hidden Text onLayout during a fold, and
|
||||||
|
// exact measurements take over whenever they arrive.
|
||||||
const naturalWidths = input.tabLabelWidths.map((labelWidth) =>
|
const naturalWidths = input.tabLabelWidths.map((labelWidth) =>
|
||||||
clamp(tabChromeWidth + labelWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth),
|
labelWidth === null
|
||||||
|
? input.metrics.minTabWidth
|
||||||
|
: clamp(tabChromeWidth + labelWidth, input.metrics.minTabWidth, input.metrics.maxTabWidth),
|
||||||
);
|
);
|
||||||
const naturalTotalWidth = naturalWidths.reduce((total, width) => total + width, 0);
|
const naturalTotalWidth = naturalWidths.reduce((total, width) => total + width, 0);
|
||||||
const minimumTotalWidth = input.metrics.minTabWidth * tabCount;
|
const minimumTotalWidth = input.metrics.minTabWidth * tabCount;
|
||||||
|
|||||||
Reference in New Issue
Block a user