| author | |
| committer | |
| log | b9bf60907fb6eef292da7f19d464a5b16f82ee6b |
| tree | bdb12c48cb3d691ac04176207a6c8ad49d5021fe |
| parent | b4af60253bf013ebd146e23d8f66af94854da5d6 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 132 insertions(+), 11 deletions(-)
apps/mobile/src/features/home/HomeRouteScreen.tsx+15-6| ... | @@ -37,7 +37,8 @@ export function HomeRouteScreen() { | ... | @@ -37,7 +37,8 @@ export function HomeRouteScreen() { |
| 37 | const nativePrimaryColumn = use(NativePrimaryColumnContext); | 37 | const nativePrimaryColumn = use(NativePrimaryColumnContext); |
| 38 | const columnMetrics = useNativeColumnLayoutMetrics(); | 38 | const columnMetrics = useNativeColumnLayoutMetrics(); |
| 39 | const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; | 39 | const headerWidth = nativePrimaryColumn ? (columnMetrics?.width ?? windowWidth) : windowWidth; |
| 40 | const { layout, panes } = useAdaptiveWorkspaceLayout(); | 40 | const { layout, panes, primarySidebarSearchQuery, setPrimarySidebarSearchQuery } = |
| 41 | useAdaptiveWorkspaceLayout(); | ||
| 41 | const projects = useProjects(); | 42 | const projects = useProjects(); |
| 42 | // Streaming turns rewrite thread shells many times a second. While a Thread | 43 | // Streaming turns rewrite thread shells many times a second. While a Thread |
| 43 | // covers Home, rebuilding this list is invisible work. | 44 | // covers Home, rebuilding this list is invisible work. |
| ... | @@ -49,7 +50,12 @@ export function HomeRouteScreen() { | ... | @@ -49,7 +50,12 @@ export function HomeRouteScreen() { |
| 49 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); | 50 | const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); |
| 50 | const { savedConnectionsById } = useSavedRemoteConnections(); | 51 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 51 | const navigation = useNavigation(); | 52 | const navigation = useNavigation(); |
| 52 | const [searchQuery, setSearchQuery] = useState(""); | 53 | const [localSearchQuery, setLocalSearchQuery] = useState(""); |
| 54 | // Android's controlled fields share search text across compact/split remounts. | ||
| 55 | // Keep iOS search local because its native fields do not restore text from this state. | ||
| 56 | const searchQuery = Platform.OS === "android" ? primarySidebarSearchQuery : localSearchQuery; | ||
| 57 | const setSearchQuery = | ||
| 58 | Platform.OS === "android" ? setPrimarySidebarSearchQuery : setLocalSearchQuery; | ||
| 53 | const handleSelectThread = useHomeThreadSelection(); | 59 | const handleSelectThread = useHomeThreadSelection(); |
| 54 | const handleNewThreadOnBranch = useCallback( | 60 | const handleNewThreadOnBranch = useCallback( |
| 55 | (thread: EnvironmentThreadShell) => { | 61 | (thread: EnvironmentThreadShell) => { |
| ... | @@ -107,10 +113,13 @@ export function HomeRouteScreen() { | ... | @@ -107,10 +113,13 @@ export function HomeRouteScreen() { |
| 107 | () => new Set(environments.map((environment) => environment.environmentId)), | 113 | () => new Set(environments.map((environment) => environment.environmentId)), |
| 108 | [environments], | 114 | [environments], |
| 109 | ); | 115 | ); |
| 110 | const { options: listOptions, setSelectedEnvironmentId } = | 116 | const { |
| 111 | useHomeListOptions(availableEnvironmentIds); | 117 | options: listOptions, |
| 118 | setSelectedEnvironmentId, | ||
| 119 | setSelectedProjectKey, | ||
| 120 | } = useHomeListOptions(availableEnvironmentIds); | ||
| 112 | const selectedEnvironmentId = listOptions.selectedEnvironmentId; | 121 | const selectedEnvironmentId = listOptions.selectedEnvironmentId; |
| 113 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); | 122 | const selectedProjectKey = listOptions.selectedProjectKey; |
| 114 | const projectFilterOptions = useMemo( | 123 | const projectFilterOptions = useMemo( |
| 115 | () => | 124 | () => |
| 116 | buildHomeProjectScopes({ | 125 | buildHomeProjectScopes({ |
| ... | @@ -130,7 +139,7 @@ export function HomeRouteScreen() { | ... | @@ -130,7 +139,7 @@ export function HomeRouteScreen() { |
| 130 | ) { | 139 | ) { |
| 131 | setSelectedProjectKey(null); | 140 | setSelectedProjectKey(null); |
| 132 | } | 141 | } |
| 133 | }, [projectFilterOptions, selectedProjectKey]); | 142 | }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]); |
| 134 | 143 | ||
| 135 | // In split layouts the persistent sidebar IS the thread list — Home becomes | 144 | // In split layouts the persistent sidebar IS the thread list — Home becomes |
| 136 | // an empty detail pane so selecting a thread never transitions layouts. | 145 | // an empty detail pane so selecting a thread never transitions layouts. |
apps/mobile/src/features/home/home-list-options.test.ts created+95| ... | @@ -0,0 +1,95 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { act, createElement, useLayoutEffect } from "react"; | ||
| 3 | import { createRoot, type Root } from "react-dom/client"; | ||
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options"; | ||
| 7 | |||
| 8 | describe("home list options across layout changes", () => { | ||
| 9 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 10 | const availableEnvironmentIds = new Set([environmentId]); | ||
| 11 | let root: Root; | ||
| 12 | let latest: ReturnType<typeof useHomeListOptions>; | ||
| 13 | |||
| 14 | function ThreadList() { | ||
| 15 | const state = useHomeListOptions(availableEnvironmentIds); | ||
| 16 | useLayoutEffect(() => { | ||
| 17 | latest = state; | ||
| 18 | }); | ||
| 19 | return null; | ||
| 20 | } | ||
| 21 | |||
| 22 | async function showLayout(layout: "compact" | "sidebar") { | ||
| 23 | await act(() => { | ||
| 24 | root.render( | ||
| 25 | createElement( | ||
| 26 | HomeListOptionsProvider, | ||
| 27 | { projectGroupingMode: "repository" }, | ||
| 28 | // Replace the list while keeping the workspace provider mounted. | ||
| 29 | createElement(ThreadList, { key: layout }), | ||
| 30 | ), | ||
| 31 | ); | ||
| 32 | }); | ||
| 33 | } | ||
| 34 | |||
| 35 | beforeEach(() => { | ||
| 36 | // The hook probe renders no DOM, but ReactDOM needs an event target. | ||
| 37 | const document = { | ||
| 38 | nodeType: 9, | ||
| 39 | addEventListener() {}, | ||
| 40 | removeEventListener() {}, | ||
| 41 | }; | ||
| 42 | const container = { | ||
| 43 | nodeType: 1, | ||
| 44 | tagName: "DIV", | ||
| 45 | namespaceURI: "http://www.w3.org/1999/xhtml", | ||
| 46 | ownerDocument: document, | ||
| 47 | addEventListener() {}, | ||
| 48 | removeEventListener() {}, | ||
| 49 | }; | ||
| 50 | vi.stubGlobal("document", document); | ||
| 51 | vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); | ||
| 52 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 53 | root = createRoot(container as unknown as HTMLElement); | ||
| 54 | }); | ||
| 55 | |||
| 56 | afterEach(async () => { | ||
| 57 | await act(() => root.unmount()); | ||
| 58 | vi.unstubAllGlobals(); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it.each([ | ||
| 62 | ["compact", "sidebar"], | ||
| 63 | ["sidebar", "compact"], | ||
| 64 | ] as const)( | ||
| 65 | "retains filters through repeated %s → %s round trips and keeps clears", | ||
| 66 | async (initialLayout, otherLayout) => { | ||
| 67 | await showLayout(initialLayout); | ||
| 68 | |||
| 69 | for (const [selectedEnvironmentId, selectedProjectKey] of [ | ||
| 70 | [environmentId, "repository:one"], | ||
| 71 | [environmentId, "repository:two"], | ||
| 72 | [environmentId, null], | ||
| 73 | [null, null], | ||
| 74 | [null, "repository:one"], | ||
| 75 | ] as const) { | ||
| 76 | await act(() => { | ||
| 77 | latest.setSelectedEnvironmentId(selectedEnvironmentId); | ||
| 78 | latest.setSelectedProjectKey(selectedProjectKey); | ||
| 79 | }); | ||
| 80 | for (let cycle = 0; cycle < 3; cycle += 1) { | ||
| 81 | for (const layout of [otherLayout, initialLayout]) { | ||
| 82 | await showLayout(layout); | ||
| 83 | expect(latest.options).toMatchObject({ | ||
| 84 | selectedEnvironmentId, | ||
| 85 | selectedProjectKey, | ||
| 86 | projectGroupingMode: "repository", | ||
| 87 | }); | ||
| 88 | } | ||
| 89 | } | ||
| 90 | // Make the next selection (or clear it) from the opposite layout. | ||
| 91 | await showLayout(otherLayout); | ||
| 92 | } | ||
| 93 | }, | ||
| 94 | ); | ||
| 95 | }); | ||
apps/mobile/src/features/home/home-list-options.ts+9| ... | @@ -16,6 +16,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList"; | ... | @@ -16,6 +16,7 @@ import type { HomeProjectSortOrder } from "./homeThreadList"; |
| 16 | 16 | ||
| 17 | export interface HomeListOptions { | 17 | export interface HomeListOptions { |
| 18 | readonly selectedEnvironmentId: EnvironmentId | null; | 18 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 19 | readonly selectedProjectKey: string | null; | ||
| 19 | readonly projectSortOrder: HomeProjectSortOrder; | 20 | readonly projectSortOrder: HomeProjectSortOrder; |
| 20 | } | 21 | } |
| 21 | 22 | ||
| ... | @@ -26,6 +27,7 @@ export interface ResolvedHomeListOptions extends HomeListOptions { | ... | @@ -26,6 +27,7 @@ export interface ResolvedHomeListOptions extends HomeListOptions { |
| 26 | function defaultHomeListOptions(): HomeListOptions { | 27 | function defaultHomeListOptions(): HomeListOptions { |
| 27 | return { | 28 | return { |
| 28 | selectedEnvironmentId: null, | 29 | selectedEnvironmentId: null, |
| 30 | selectedProjectKey: null, | ||
| 29 | projectSortOrder: | 31 | projectSortOrder: |
| 30 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" | 32 | DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" |
| 31 | ? "updated_at" | 33 | ? "updated_at" |
| ... | @@ -78,12 +80,19 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<Environm | ... | @@ -78,12 +80,19 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet<Environm |
| 78 | const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => { | 80 | const setSelectedEnvironmentId = useCallback((value: EnvironmentId | null) => { |
| 79 | setOptions((current) => ({ ...current, selectedEnvironmentId: value })); | 81 | setOptions((current) => ({ ...current, selectedEnvironmentId: value })); |
| 80 | }, []); | 82 | }, []); |
| 83 | const setSelectedProjectKey = useCallback( | ||
| 84 | (value: string | null) => { | ||
| 85 | setOptions((current) => ({ ...current, selectedProjectKey: value })); | ||
| 86 | }, | ||
| 87 | [setOptions], | ||
| 88 | ); | ||
| 81 | const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => { | 89 | const setProjectSortOrder = useCallback((value: HomeProjectSortOrder) => { |
| 82 | setOptions((current) => ({ ...current, projectSortOrder: value })); | 90 | setOptions((current) => ({ ...current, projectSortOrder: value })); |
| 83 | }, []); | 91 | }, []); |
| 84 | return { | 92 | return { |
| 85 | options: resolvedOptions, | 93 | options: resolvedOptions, |
| 86 | setSelectedEnvironmentId, | 94 | setSelectedEnvironmentId, |
| 95 | setSelectedProjectKey, | ||
| 87 | setProjectSortOrder, | 96 | setProjectSortOrder, |
| 88 | } as const; | 97 | } as const; |
| 89 | } | 98 | } |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+13-5| ... | @@ -175,7 +175,8 @@ function ThreadNavigationSidebarPane( | ... | @@ -175,7 +175,8 @@ function ThreadNavigationSidebarPane( |
| 175 | () => new Set(environments.map((environment) => environment.environmentId)), | 175 | () => new Set(environments.map((environment) => environment.environmentId)), |
| 176 | [environments], | 176 | [environments], |
| 177 | ); | 177 | ); |
| 178 | const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds); | 178 | const { options, setSelectedEnvironmentId, setSelectedProjectKey } = |
| 179 | useHomeListOptions(availableEnvironmentIds); | ||
| 179 | const searchEnvironmentIds = useMemo( | 180 | const searchEnvironmentIds = useMemo( |
| 180 | () => | 181 | () => |
| 181 | options.selectedEnvironmentId === null | 182 | options.selectedEnvironmentId === null |
| ... | @@ -205,7 +206,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -205,7 +206,7 @@ function ThreadNavigationSidebarPane( |
| 205 | () => new Set(threadSearch.matches.map(threadSearchMatchKey)), | 206 | () => new Set(threadSearch.matches.map(threadSearchMatchKey)), |
| 206 | [threadSearch.matches], | 207 | [threadSearch.matches], |
| 207 | ); | 208 | ); |
| 208 | const [selectedProjectKey, setSelectedProjectKey] = useState<string | null>(null); | 209 | const selectedProjectKey = options.selectedProjectKey; |
| 209 | const projectScopes = useMemo( | 210 | const projectScopes = useMemo( |
| 210 | () => | 211 | () => |
| 211 | buildHomeProjectScopes({ | 212 | buildHomeProjectScopes({ |
| ... | @@ -252,7 +253,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -252,7 +253,7 @@ function ThreadNavigationSidebarPane( |
| 252 | ) { | 253 | ) { |
| 253 | setSelectedProjectKey(null); | 254 | setSelectedProjectKey(null); |
| 254 | } | 255 | } |
| 255 | }, [projectFilterOptions, selectedProjectKey]); | 256 | }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]); |
| 256 | const selectedProjectRefs = useMemo( | 257 | const selectedProjectRefs = useMemo( |
| 257 | () => | 258 | () => |
| 258 | selectedProjectScope === null | 259 | selectedProjectScope === null |
| ... | @@ -550,7 +551,7 @@ function ThreadNavigationSidebarPane( | ... | @@ -550,7 +551,7 @@ function ThreadNavigationSidebarPane( |
| 550 | return; | 551 | return; |
| 551 | } | 552 | } |
| 552 | }, | 553 | }, |
| 553 | [environments, projectFilterOptions, setSelectedEnvironmentId], | 554 | [environments, projectFilterOptions, setSelectedEnvironmentId, setSelectedProjectKey], |
| 554 | ); | 555 | ); |
| 555 | 556 | ||
| 556 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); | 557 | const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null); |
| ... | @@ -853,7 +854,14 @@ function ThreadNavigationSidebarPane( | ... | @@ -853,7 +854,14 @@ function ThreadNavigationSidebarPane( |
| 853 | onEnvironmentChange: setSelectedEnvironmentId, | 854 | onEnvironmentChange: setSelectedEnvironmentId, |
| 854 | onProjectChange: setSelectedProjectKey, | 855 | onProjectChange: setSelectedProjectKey, |
| 855 | }), | 856 | }), |
| 856 | [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId], | 857 | [ |
| 858 | environments, | ||
| 859 | options, | ||
| 860 | projectFilterOptions, | ||
| 861 | selectedProjectKey, | ||
| 862 | setSelectedEnvironmentId, | ||
| 863 | setSelectedProjectKey, | ||
| 864 | ], | ||
| 857 | ); | 865 | ); |
| 858 | const nativeHeaderItems = useMemo( | 866 | const nativeHeaderItems = useMemo( |
| 859 | () => | 867 | () => |