mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d96ee1522 | ||
|
|
aa50151fa1 | ||
|
|
bf4979fd62 |
Generated
+11
@@ -21273,6 +21273,16 @@
|
||||
"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": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
||||
@@ -36473,6 +36483,7 @@
|
||||
"expo-localization": "~17.0.9",
|
||||
"expo-notifications": "^0.32.16",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-screen-orientation": "~9.0.9",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
|
||||
@@ -3,6 +3,7 @@ const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||
const withAndroidFoldConfiguration = require("./plugins/with-android-fold-configuration");
|
||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||
const withPasteInput = require("./plugins/with-paste-input");
|
||||
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
|
||||
@@ -100,7 +101,8 @@ export default {
|
||||
name: variant.name,
|
||||
slug: "voice-mobile",
|
||||
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",
|
||||
scheme: "paseo",
|
||||
userInterfaceStyle: "automatic",
|
||||
@@ -108,6 +110,12 @@ export default {
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
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.",
|
||||
ITSAppUsesNonExemptEncryption: false,
|
||||
},
|
||||
@@ -142,6 +150,7 @@ export default {
|
||||
plugins: [
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidFoldConfiguration,
|
||||
withAndroidScroll,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...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-notifications": "^0.32.16",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-screen-orientation": "~9.0.9",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.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,
|
||||
useSyncExternalStore,
|
||||
} 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 { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { MeasuredWindowWidthProvider, useLayoutWindowWidth } from "@/constants/window-width";
|
||||
import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppChromeLayout,
|
||||
@@ -87,6 +88,7 @@ import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-ac
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useAdaptiveOrientation } from "@/orientation";
|
||||
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
@@ -457,6 +459,7 @@ interface AppContainerProps {
|
||||
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
|
||||
|
||||
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
|
||||
const isOrientationReady = useAdaptiveOrientation();
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const daemons = useHosts();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
@@ -466,7 +469,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const viewportWidth = useLayoutWindowWidth();
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
void updateSettings({ theme: getNextThemePreference(settings.theme) });
|
||||
@@ -622,6 +625,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
surface
|
||||
);
|
||||
|
||||
if (!isOrientationReady) return null;
|
||||
|
||||
return <CommandCenterProvider>{content}</CommandCenterProvider>;
|
||||
}
|
||||
|
||||
@@ -967,16 +972,23 @@ function RootProviders({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
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 (
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
<MeasuredWindowWidthProvider width={measuredWindowWidth}>
|
||||
<GestureHandlerRootView style={flexStyle} onLayout={handleRootLayout}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</MeasuredWindowWidthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Pressable,
|
||||
StyleSheet as RNStyleSheet,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
@@ -29,6 +28,7 @@ import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useLayoutWindowWidth } from "@/constants/window-width";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
@@ -678,7 +678,7 @@ function DesktopSidebar({
|
||||
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const viewportWidth = useLayoutWindowWidth();
|
||||
const visibleSidebarWidth = resolveDesktopSidebarWidth({
|
||||
requestedWidth: sidebarWidth,
|
||||
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 { useWindowDimensions } from "react-native";
|
||||
import { useLayoutWindowWidth } from "@/constants/window-width";
|
||||
import {
|
||||
computeWorkspaceTabLayout,
|
||||
type WorkspaceTabLayoutMetrics,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@/screens/workspace/workspace-tab-layout";
|
||||
|
||||
interface UseWorkspaceTabLayoutInput {
|
||||
tabLabelWidths: number[];
|
||||
tabLabelWidths: (number | null)[];
|
||||
viewportWidthOverride?: number | null;
|
||||
metrics: WorkspaceTabLayoutMetrics;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ interface UseWorkspaceTabLayoutResult {
|
||||
export function useWorkspaceTabLayout(
|
||||
input: UseWorkspaceTabLayoutInput,
|
||||
): UseWorkspaceTabLayoutResult {
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const viewportWidth = useLayoutWindowWidth();
|
||||
const resolvedViewportWidth =
|
||||
typeof input.viewportWidthOverride === "number" && input.viewportWidthOverride > 0
|
||||
? input.viewportWidthOverride
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { Pressable, Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { Pressable, Text, View, type LayoutChangeEvent, type ScrollView } from "react-native";
|
||||
import {
|
||||
CopyX,
|
||||
ArrowLeftToLine,
|
||||
@@ -49,7 +49,10 @@ import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
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 {
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
@@ -459,39 +462,25 @@ interface WorkspaceTabLabelMeasurement {
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface WorkspaceTabTrackSnapshot {
|
||||
signature: string;
|
||||
tabs: ResolvedWorkspaceDesktopTabRowItem[];
|
||||
labels: WorkspaceTabLabel[];
|
||||
labelWidths: number[];
|
||||
}
|
||||
|
||||
function workspaceTabLabelSignature(labels: WorkspaceTabLabel[]): string {
|
||||
return JSON.stringify(labels);
|
||||
}
|
||||
|
||||
function completeWorkspaceTabLabelWidths(
|
||||
function resolveWorkspaceTabLabelWidths(
|
||||
labels: WorkspaceTabLabel[],
|
||||
measurements: Map<string, WorkspaceTabLabelMeasurement>,
|
||||
): number[] | null {
|
||||
const widths: number[] = [];
|
||||
): (number | null)[] {
|
||||
const widths: (number | null)[] = [];
|
||||
for (const { key, label, modified } of labels) {
|
||||
const measurement = measurements.get(key);
|
||||
if (!measurement || measurement.label !== label || measurement.width <= 0) {
|
||||
return null;
|
||||
}
|
||||
const labelWidth =
|
||||
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
|
||||
// before its label starts truncating.
|
||||
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;
|
||||
}
|
||||
|
||||
function sameWidths(left: number[], right: number[]): boolean {
|
||||
return left.length === right.length && left.every((width, index) => width === right[index]);
|
||||
}
|
||||
|
||||
interface WorkspaceDesktopTabsRowProps {
|
||||
paneId?: string;
|
||||
isFocused?: boolean;
|
||||
@@ -536,8 +525,6 @@ interface WorkspaceDesktopTabPresentationSlotProps {
|
||||
onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void;
|
||||
}
|
||||
|
||||
const EMPTY_RESOLVED_TAB_ROWS: ResolvedWorkspaceDesktopTabRowItem[] = [];
|
||||
|
||||
function WorkspaceDesktopTabPresentationSlot({
|
||||
tab,
|
||||
serverId,
|
||||
@@ -1022,12 +1009,14 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
const { t } = useTranslation();
|
||||
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||
const [tabsScrollViewportWidth, setTabsScrollViewportWidth] = useState<number>(0);
|
||||
const tabsScrollRef = useRef<ScrollView>(null);
|
||||
const [exitFocusModeWidth, setExitFocusModeWidth] = useState<number>(0);
|
||||
const tabScrollBoundary = useHorizontalScrollBoundary();
|
||||
const { onLayout: onTabBoundaryLayout } = tabScrollBoundary;
|
||||
const [labelMeasurements, setLabelMeasurements] = useState(
|
||||
() => new Map<string, WorkspaceTabLabelMeasurement>(),
|
||||
);
|
||||
const [trackSnapshot, setTrackSnapshot] = useState<WorkspaceTabTrackSnapshot | null>(null);
|
||||
|
||||
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
updateMeasuredWidth(setTabsContainerWidth, event);
|
||||
@@ -1101,7 +1090,6 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
}),
|
||||
[fallbackTabLabels, tabs],
|
||||
);
|
||||
const tabLabelSignature = useMemo(() => workspaceTabLabelSignature(tabLabels), [tabLabels]);
|
||||
const currentTabLabelKeys = useMemo(() => new Set(tabLabels.map(({ key }) => key)), [tabLabels]);
|
||||
useEffect(() => {
|
||||
setLabelMeasurements((current) => {
|
||||
@@ -1116,35 +1104,6 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
return next;
|
||||
});
|
||||
}, [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(
|
||||
(key: string, label: string, event: LayoutChangeEvent) => {
|
||||
const width = Math.ceil(event.nativeEvent.layout.width);
|
||||
@@ -1164,25 +1123,46 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
[],
|
||||
);
|
||||
|
||||
const displayedTabs = useMemo(() => {
|
||||
if (!trackSnapshot) {
|
||||
return EMPTY_RESOLVED_TAB_ROWS;
|
||||
}
|
||||
const currentTabs = new Map(
|
||||
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 tabLabelWidths = useMemo(
|
||||
() => resolveWorkspaceTabLabelWidths(tabLabels, labelMeasurements),
|
||||
[tabLabels, labelMeasurements],
|
||||
);
|
||||
const displayedTabs = tabs;
|
||||
|
||||
const { layout } = useWorkspaceTabLayout({
|
||||
tabLabelWidths: trackSnapshot?.labelWidths ?? [],
|
||||
tabLabelWidths,
|
||||
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
|
||||
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(
|
||||
(nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => {
|
||||
onReorderTabs(nextTabs.map((tab) => tab.tab));
|
||||
@@ -1336,13 +1316,14 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
/>
|
||||
<View style={styles.tabsScrollContainer}>
|
||||
<Animated.ScrollView
|
||||
ref={tabsScrollRef}
|
||||
horizontal
|
||||
scrollEnabled={layout.requiresHorizontalScrollFallback}
|
||||
testID="workspace-tabs-scroll"
|
||||
style={tabsScrollStyle}
|
||||
contentContainerStyle={styles.tabsContent}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onLayout={tabScrollBoundary.onLayout}
|
||||
onLayout={handleTabsScrollLayout}
|
||||
onContentSizeChange={tabScrollBoundary.onContentSizeChange}
|
||||
onScroll={tabScrollBoundary.onScroll}
|
||||
scrollEventThrottle={16}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeWorkspaceTabLayout,
|
||||
retainWorkspaceTabMeasuredWidth,
|
||||
scrollOffsetToRevealWorkspaceTab,
|
||||
} from "@/screens/workspace/workspace-tab-layout";
|
||||
|
||||
const metrics = {
|
||||
@@ -120,6 +121,33 @@ describe("computeWorkspaceTabLayout", () => {
|
||||
|
||||
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", () => {
|
||||
@@ -131,3 +159,15 @@ describe("retainWorkspaceTabMeasuredWidth", () => {
|
||||
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 {
|
||||
viewportWidth: number;
|
||||
tabLabelWidths: number[];
|
||||
tabLabelWidths: (number | null)[];
|
||||
metrics: WorkspaceTabLayoutMetrics;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,21 @@ export function retainWorkspaceTabMeasuredWidth(
|
||||
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 {
|
||||
if (value < min) {
|
||||
return min;
|
||||
@@ -74,8 +89,13 @@ export function computeWorkspaceTabLayout(
|
||||
input.metrics.tabContentGap +
|
||||
input.metrics.tabHorizontalPadding * 2 +
|
||||
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) =>
|
||||
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 minimumTotalWidth = input.metrics.minTabWidth * tabCount;
|
||||
|
||||
Reference in New Issue
Block a user