Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
"react-native-keyboard-controller": "1.22.6",
"react-native-reanimated": "4.7.0",
"react-native-safe-area-context": "~5.9.1",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.25.tgz",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.26.tgz",
"react-native-shiki-engine": "^0.3.12",
"react-native-svg": "15.15.5",
"react-native-webview": "14.0.1",
Expand Down
22 changes: 10 additions & 12 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,15 @@ export function HomeRouteScreen() {
} = useHomeListOptions(availableEnvironmentIds);
const selectedEnvironmentId = listOptions.selectedEnvironmentId;
const projectScopeKey = listOptions.selectedProjectKey;
const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
sidebarProjectGroupingMode: listOptions.projectGroupingMode,
sidebarProjectGroupingOverrides: {},
});
useEffect(() => {
if (selectedProjectKey !== projectScopeKey) {
setSelectedProjectKey(selectedProjectKey);
if (resolvedProjectKey !== projectScopeKey) {
setSelectedProjectKey(resolvedProjectKey);
}
}, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]);
}, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]);
const projectFilterOptions = useMemo(
() =>
buildHomeProjectScopes({
Expand All @@ -148,14 +148,12 @@ export function HomeRouteScreen() {
})),
[listOptions.projectGroupingMode, projects, selectedEnvironmentId],
);
useEffect(() => {
if (
selectedProjectKey !== null &&
!projectFilterOptions.some((project) => project.key === selectedProjectKey)
) {
setSelectedProjectKey(null);
}
}, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]);
// A saved project stays selected while its environment is offline or filtered out.
const selectedProjectKey = projectFilterOptions.some(
(project) => project.key === resolvedProjectKey,
)
? resolvedProjectKey
: null;

// In split layouts the persistent sidebar IS the thread list — Home becomes
// an empty detail pane so selecting a thread never transitions layouts.
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/home/ThreadListFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function ThreadListFilters(
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16, gap: 6, paddingBottom: 8 }}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 2, paddingBottom: 10, gap: 8 }}
>
<ListFilterMenu
label={selectedEnvironment?.label ?? "All environments"}
Expand Down Expand Up @@ -85,7 +85,7 @@ export function ThreadListFilters(
<Pressable
accessibilityRole="button"
accessibilityLabel="Reset thread filters"
className="h-11 w-8 items-center justify-center"
className="h-8 w-8 items-center justify-center"
onPress={() => {
props.onEnvironmentChange(null);
props.onProjectChange(null);
Expand Down
45 changes: 40 additions & 5 deletions apps/mobile/src/features/home/home-list-options.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import { RegistryContext } from "@effect/atom-react";
import { EnvironmentId } from "@t3tools/contracts";
import { AtomRegistry } from "effect/reactivity";
import { act, createElement, useLayoutEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

const saved = vi.hoisted(() => [] as unknown[]);
vi.mock("../../state/preferences", async () => {
const { Atom } = await import("effect/reactivity");
return {
updateMobilePreferencesAtom: Atom.writable(
() => null,
(_ctx, patch: unknown) => {
saved.push(patch);
},
),
};
});

import type { ThreadListFilters } from "../../persistence/mobile-preferences";
import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options";

describe("home list options across layout changes", () => {
Expand All @@ -19,14 +35,20 @@ describe("home list options across layout changes", () => {
return null;
}

async function showLayout(layout: "compact" | "sidebar") {
const registry = AtomRegistry.make();

async function showLayout(layout: "compact" | "sidebar", savedFilters?: ThreadListFilters) {
await act(() => {
root.render(
createElement(
HomeListOptionsProvider,
{ projectGroupingMode: "repository" },
// Replace the list while keeping the workspace provider mounted.
createElement(ThreadList, { key: layout }),
RegistryContext,
{ value: registry },
createElement(
HomeListOptionsProvider,
{ projectGroupingMode: "repository", savedFilters },
// Replace the list while keeping the workspace provider mounted.
createElement(ThreadList, { key: layout }),
),
),
);
});
Expand All @@ -51,6 +73,7 @@ describe("home list options across layout changes", () => {
vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
root = createRoot(container as unknown as HTMLElement);
saved.length = 0;
});

afterEach(async () => {
Expand Down Expand Up @@ -92,4 +115,16 @@ describe("home list options across layout changes", () => {
}
},
);

it("restores saved filters on launch and saves only real changes", async () => {
await showLayout("compact", { environmentId, projectKey: "repository:one" });
expect(latest.options).toMatchObject({
selectedEnvironmentId: environmentId,
selectedProjectKey: "repository:one",
});
expect(saved).toEqual([]);

await act(() => latest.setSelectedProjectKey(null));
expect(saved).toEqual([{ threadListFilters: { environmentId, projectKey: null } }]);
});
});
32 changes: 27 additions & 5 deletions apps/mobile/src/features/home/home-list-options.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts";
import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts";
import { useAtomSet } from "@effect/atom-react";
import {
createContext,
createElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type Dispatch,
type SetStateAction,
} from "react";

import type { ThreadListFilters } from "../../persistence/mobile-preferences";
import { updateMobilePreferencesAtom } from "../../state/preferences";
import type { HomeProjectSortOrder } from "./homeThreadList";

export interface HomeListOptions {
Expand All @@ -24,10 +29,10 @@ export interface ResolvedHomeListOptions extends HomeListOptions {
readonly projectGroupingMode: SidebarProjectGroupingMode;
}

function defaultHomeListOptions(): HomeListOptions {
function defaultHomeListOptions(filters?: ThreadListFilters): HomeListOptions {
return {
selectedEnvironmentId: null,
selectedProjectKey: null,
selectedEnvironmentId: filters?.environmentId ?? null,
selectedProjectKey: filters?.projectKey ?? null,
projectSortOrder:
DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
? "updated_at"
Expand All @@ -43,14 +48,31 @@ interface HomeListOptionsContextValue {

const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null>(null);

/** Keeps list preferences stable while the app moves between compact and split shells. */
/**
* Keeps list preferences stable while the app moves between compact and split shells.
* Filters start from the saved preference, and changes are saved for the next launch.
*/
export function HomeListOptionsProvider({
children,
projectGroupingMode,
savedFilters,
}: PropsWithChildren<{
readonly projectGroupingMode: SidebarProjectGroupingMode;
readonly savedFilters?: ThreadListFilters | undefined;
}>) {
const [options, setOptions] = useState<HomeListOptions>(defaultHomeListOptions);
const [options, setOptions] = useState(() => defaultHomeListOptions(savedFilters));
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const saved = useRef(savedFilters);
const { selectedEnvironmentId: environmentId, selectedProjectKey: projectKey } = options;
useEffect(() => {
if (
(saved.current?.environmentId ?? null) === environmentId &&
(saved.current?.projectKey ?? null) === projectKey
)
return;
saved.current = { environmentId, projectKey };
savePreferences({ threadListFilters: saved.current });
}, [environmentId, projectKey, savePreferences]);
const value = useMemo(
() => ({ options, setOptions, projectGroupingMode }),
[options, projectGroupingMode],
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
resolveThreadSelectionOverlayState,
} from "../../lib/adaptive-navigation";
import { scopedThreadKey } from "../../lib/scopedEntities";
import type { ThreadListFilters } from "../../persistence/mobile-preferences";
import { mobilePreferencesAtom } from "../../state/preferences";
import {
DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS,
Expand Down Expand Up @@ -226,6 +227,7 @@ export function AdaptiveWorkspaceLayout(props: {
<AdaptiveWorkspaceLayoutContent
{...props}
projectGroupingMode={groupingSettings.sidebarProjectGroupingMode}
savedThreadListFilters={preferencesResult.value.threadListFilters}
/>
);
}
Expand All @@ -237,6 +239,7 @@ function AdaptiveWorkspaceLayoutContent(
readonly workspaceRouteKey: string | undefined;
} & {
readonly projectGroupingMode: SidebarProjectGroupingMode;
readonly savedThreadListFilters?: ThreadListFilters | undefined;
},
) {
const projectGroupingMode = props.projectGroupingMode;
Expand Down Expand Up @@ -592,7 +595,10 @@ function AdaptiveWorkspaceLayoutContent(

if (nativeWorkspace) {
return (
<HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>
<HomeListOptionsProvider
projectGroupingMode={projectGroupingMode}
savedFilters={props.savedThreadListFilters}
>
<AdaptiveWorkspaceContext value={contextValue}>
<NativeWorkspaceInspectorContext
value={{ render: workspaceInspector?.render, visible: inspectorColumnTargetWidth > 0 }}
Expand All @@ -604,7 +610,10 @@ function AdaptiveWorkspaceLayoutContent(
);
}
return (
<HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>
<HomeListOptionsProvider
projectGroupingMode={projectGroupingMode}
savedFilters={props.savedThreadListFilters}
>
<AdaptiveWorkspaceContext.Provider value={contextValue}>
<View testID="adaptive-workspace-layout" className="flex-1 flex-row">
{shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export function PullRequestListFilters(props: {
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16, gap: 6, paddingBottom: 8 }}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 2, paddingBottom: 10, gap: 8 }}
>
<ListFilterMenu
label={count ? String(count) : "Filters"}
Expand Down
16 changes: 8 additions & 8 deletions apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
import { useNavigation } from "@react-navigation/native";
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ProjectId } from "@t3tools/contracts";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { useAtomValue } from "@effect/atom-react";
import {
ActivityIndicator,
Keyboard,
Expand All @@ -24,7 +24,8 @@ import { useProjects } from "../../state/entities";
import { environmentServerConfigsAtom } from "../../state/server";
import { PullRequestListRow } from "./PullRequestListRow";
import { PullRequestListFilters } from "./PullRequestListFilters";
import { pullRequestListControlsAtom, usePullRequestList } from "./usePullRequestList";
import { usePullRequestList } from "./usePullRequestList";
import { usePullRequestListControls } from "./usePullRequestListControls";
import { usePullRequestListActions } from "./usePullRequestListActions";
import {
filterPullRequestList,
Expand Down Expand Up @@ -73,7 +74,8 @@ export function PullRequestsScreen() {
const debouncedQuery = useDebouncedValue(tab.query.trim(), 180);
// Dismissal must restore the cached unfiltered list before restoring its offset.
const query = tab.searchMode === "resting" ? "" : debouncedQuery;
const selectedEnvironmentId = workspace.pullRequestEnvironmentId;
const listControls = usePullRequestListControls();
const selectedEnvironmentId = listControls.environmentId;
const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
? selectedEnvironmentId
: null;
Expand All @@ -91,9 +93,7 @@ export function PullRequestsScreen() {
},
[navigation],
);
const controlsAtom = pullRequestListControlsAtom(environmentId ?? "");
const controls = useAtomValue(controlsAtom);
const setControls = useAtomSet(controlsAtom);
const controls = listControls.controlsFor(environmentId);
const targets = useMemo(
() =>
environments
Expand Down Expand Up @@ -265,10 +265,10 @@ export function PullRequestsScreen() {
label: item.environmentLabel,
}))}
environmentId={environmentId}
onEnvironmentChange={workspace.setPullRequestEnvironmentId}
onEnvironmentChange={listControls.setEnvironmentId}
value={controls}
onChange={(next) => {
setControls(next);
listControls.setControls(environmentId, next);
void listRef.current?.scrollToOffset({
offset: Platform.OS === "ios" ? -insets.top : 0,
animated: false,
Expand Down
13 changes: 2 additions & 11 deletions apps/mobile/src/features/pull-requests/pullRequestList.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
import type {
EnvironmentId,
PullRequestListEntry,
PullRequestListFilters,
PullRequestListInput,
PullRequestListResult,
PullRequestInvolvement,
PullRequestListState,
} from "@t3tools/contracts";
import type { PullRequestListControls } from "../../persistence/mobile-preferences";

export const PR_LIST_PAGE_SIZE = 30;
export interface PullRequestListControls {
readonly state: PullRequestListState;
readonly involvement: PullRequestInvolvement;
readonly filters: PullRequestListFilters;
readonly projectId?: string;
readonly projectEnvironmentId?: string;
readonly sort: "updated" | "created" | "oldest";
}
export type { PullRequestListControls } from "../../persistence/mobile-preferences";
export const DEFAULT_PR_LIST_CONTROLS: PullRequestListControls = {
state: "open",
involvement: "all",
Expand Down
5 changes: 0 additions & 5 deletions apps/mobile/src/features/pull-requests/usePullRequestList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { use, useCallback, useEffect, useMemo, useRef, useState, useLayoutEffect
import { composerPullRequests } from "../../state/pull-requests";
import {
appendPullRequestListPage,
DEFAULT_PR_LIST_CONTROLS,
mergeEnvironmentPullRequestPages,
nextPullRequestListInput,
pullRequestListHead,
Expand All @@ -28,10 +27,6 @@ const statsFamily = Atom.family((_environmentId: string) =>
const statKey = (row: Pick<PullRequestDiffStat, "projectId" | "repository" | "number">) =>
`${row.projectId}:${row.repository}:${row.number}`;

export const pullRequestListControlsAtom = Atom.family((_environmentId: string) =>
Atom.make(DEFAULT_PR_LIST_CONTROLS),
);

type ListTarget = { readonly environmentId: EnvironmentId; readonly input: PullRequestListInput };
const sourcesFamily = Atom.family((key: string) => {
const targets: readonly ListTarget[] = JSON.parse(key);
Expand Down
Loading
Loading