Skip to content

Commit 60e8abf

Browse files
authored
Merge pull request #238 from pylon-code/upstream/2026-09-01-mobile-web-small
fix(mobile,web): adopt upstream small fixes
2 parents 22e8c2b + 0fc44e2 commit 60e8abf

8 files changed

Lines changed: 267 additions & 17 deletions

File tree

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
22
import {
3+
CommonActions,
34
StackActions,
45
useFocusEffect,
56
useNavigation,
67
usePreventRemove,
8+
type NavigationAction,
79
} from "@react-navigation/native";
810
import { useCallback, useEffect, useRef, useState } from "react";
911
import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
@@ -223,6 +225,9 @@ export function NewTaskDraftScreen(props: {
223225
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
224226
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
225227
const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false);
228+
const [submitNavigationAction, setSubmitNavigationAction] = useState<NavigationAction | null>(
229+
null,
230+
);
226231
const [shareImportAttempt, setShareImportAttempt] = useState(0);
227232
const startedShareImportKeyRef = useRef<string | null>(null);
228233
const cancellingShareImportKeyRef = useRef<string | null>(null);
@@ -290,12 +295,23 @@ export function NewTaskDraftScreen(props: {
290295
voiceInput.elapsedSeconds,
291296
);
292297
const isVoiceInputPresented = voicePresentation.statusLabel !== null;
293-
usePreventRemove(
298+
const preventRemove =
294299
(isIncomingShareTransferPending && !isProjectPickerReturnActive) ||
295-
isCancellingShareImport ||
296-
flow.submitting,
297-
() => undefined,
298-
);
300+
isCancellingShareImport ||
301+
flow.submitting;
302+
usePreventRemove(preventRemove, () => undefined);
303+
useEffect(() => {
304+
if (preventRemove || submitNavigationAction === null) {
305+
return;
306+
}
307+
// Give the guard update a frame to reach the parent sheet before navigating,
308+
// just like the project-picker fallback below.
309+
const frame = requestAnimationFrame(() => {
310+
setSubmitNavigationAction(null);
311+
(navigation.getParent() ?? navigation).dispatch(submitNavigationAction);
312+
});
313+
return () => cancelAnimationFrame(frame);
314+
}, [navigation, preventRemove, submitNavigationAction]);
299315
const hasImportedIncomingShare = Boolean(
300316
props.incomingShareId &&
301317
flow.draftKey &&
@@ -906,7 +922,7 @@ export function NewTaskDraftScreen(props: {
906922
clearWorkspaceSelection: true,
907923
});
908924
}
909-
navigation.getParent()?.goBack();
925+
setSubmitNavigationAction(CommonActions.goBack());
910926
return;
911927
}
912928

@@ -977,7 +993,7 @@ export function NewTaskDraftScreen(props: {
977993
clearWorkspaceSelection: true,
978994
});
979995
}
980-
navigation.dispatch(
996+
setSubmitNavigationAction(
981997
StackActions.replace("Thread", {
982998
environmentId: String(result.value.environmentId),
983999
threadId: String(result.value.threadId),

apps/web/src/components/files/FileBrowserPanel.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import type {
33
ContextMenuOpenContext as TreeContextMenuOpenContext,
44
} from "@pierre/trees";
55
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
6-
import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react";
6+
import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";
77
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
8-
import { RotateCw } from "lucide-react";
8+
import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react";
99
import { useEffect, useMemo, useRef } from "react";
1010

1111
import { Button } from "~/components/ui/button";
@@ -20,6 +20,7 @@ import { readLocalApi } from "~/localApi";
2020
import { T3_PIERRE_ICONS } from "~/pierre-icons";
2121

2222
import { createFileTreeDragMentionController } from "./fileTreeDragMention";
23+
import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
2324
import { useProjectEntriesQuery } from "./projectFilesQueryState";
2425

2526
interface FileBrowserPanelProps {
@@ -118,6 +119,10 @@ export default function FileBrowserPanel({
118119
);
119120
const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
120121
const treePaths = useMemo(() => entries.map(treePath), [entries]);
122+
const directoryPaths = useMemo(
123+
() => entries.filter((entry) => entry.kind === "directory").map(treePath),
124+
[entries],
125+
);
121126
const previousTreePathsRef = useRef<readonly string[]>([]);
122127
const syncingSelectionRef = useRef(false);
123128
const treeSelectionPathRef = useRef<string | null>(null);
@@ -249,6 +254,12 @@ export default function FileBrowserPanel({
249254
unsafeCSS: TREE_UNSAFE_CSS,
250255
});
251256
const search = useFileTreeSearch(model);
257+
const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
258+
areAllDirectoriesExpanded(currentModel, directoryPaths),
259+
);
260+
const toggleAllDirectories = () => {
261+
setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded);
262+
};
252263
const handleSearchValueChange = (value: string) => {
253264
if (value.trim().length === 0) {
254265
search.close();
@@ -367,6 +378,32 @@ export default function FileBrowserPanel({
367378
onValueChange={handleSearchValueChange}
368379
onClose={search.close}
369380
/>
381+
{directoryPaths.length > 0 ? (
382+
<Tooltip>
383+
<TooltipTrigger
384+
render={
385+
<Button
386+
type="button"
387+
size="icon-xs"
388+
variant="ghost"
389+
aria-label={
390+
allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
391+
}
392+
onClick={toggleAllDirectories}
393+
/>
394+
}
395+
>
396+
{allDirectoriesExpanded ? (
397+
<ChevronsDownUpIcon className="size-3.5" />
398+
) : (
399+
<ChevronsUpDownIcon className="size-3.5" />
400+
)}
401+
</TooltipTrigger>
402+
<TooltipPopup>
403+
{allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
404+
</TooltipPopup>
405+
</Tooltip>
406+
) : null}
370407
</div>
371408
{entriesQuery.error && entriesQuery.data === null ? (
372409
<div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it, vi } from "@effect/vitest";
2+
3+
import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
4+
5+
type FakeDirectoryItem = {
6+
isDirectory: () => true;
7+
isExpanded: () => boolean;
8+
expand: () => void;
9+
collapse: () => void;
10+
};
11+
12+
function makeModel(expanded: Record<string, boolean>) {
13+
const items = new Map<string, FakeDirectoryItem>();
14+
return {
15+
getItem: (path: string) => {
16+
const existing = items.get(path);
17+
if (existing !== undefined) return existing;
18+
const item: FakeDirectoryItem = {
19+
isDirectory: () => true,
20+
isExpanded: () => expanded[path] ?? false,
21+
expand: () => {
22+
expanded[path] = true;
23+
},
24+
collapse: () => {
25+
expanded[path] = false;
26+
},
27+
};
28+
items.set(path, item);
29+
return item;
30+
},
31+
};
32+
}
33+
34+
describe("file tree expansion", () => {
35+
it("requires at least one directory and detects whether all are expanded", () => {
36+
const model = makeModel({ "src/": true, "test/": true });
37+
expect(areAllDirectoriesExpanded(model, [])).toBe(false);
38+
expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true);
39+
expect(
40+
areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]),
41+
).toBe(false);
42+
});
43+
44+
it("expands and collapses every directory", () => {
45+
const expanded = { "src/": true, "test/": false };
46+
const model = makeModel(expanded);
47+
setAllDirectoriesExpanded(model, ["src/", "test/"], true);
48+
expect(expanded).toEqual({ "src/": true, "test/": true });
49+
setAllDirectoriesExpanded(model, ["src/", "test/"], false);
50+
expect(expanded).toEqual({ "src/": false, "test/": false });
51+
});
52+
53+
it("skips directories already at the requested state", () => {
54+
const model = makeModel({ "src/": true });
55+
const item = model.getItem("src/");
56+
const collapse = vi.spyOn(item, "collapse");
57+
setAllDirectoriesExpanded(model, ["src/"], true);
58+
expect(collapse).not.toHaveBeenCalled();
59+
});
60+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export interface FileTreeExpansionModel {
2+
getItem(path: string): unknown;
3+
}
4+
5+
type DirectoryHandle = {
6+
isDirectory(): boolean;
7+
isExpanded(): boolean;
8+
expand(): void;
9+
collapse(): void;
10+
};
11+
12+
function asDirectoryHandle(item: unknown): DirectoryHandle | null {
13+
if (
14+
typeof item !== "object" ||
15+
item === null ||
16+
!("isDirectory" in item) ||
17+
typeof item.isDirectory !== "function" ||
18+
!item.isDirectory() ||
19+
!("isExpanded" in item) ||
20+
typeof item.isExpanded !== "function" ||
21+
!("expand" in item) ||
22+
typeof item.expand !== "function" ||
23+
!("collapse" in item) ||
24+
typeof item.collapse !== "function"
25+
) {
26+
return null;
27+
}
28+
return item as DirectoryHandle;
29+
}
30+
31+
export function areAllDirectoriesExpanded(
32+
model: FileTreeExpansionModel,
33+
directoryPaths: readonly string[],
34+
): boolean {
35+
return (
36+
directoryPaths.length > 0 &&
37+
directoryPaths.every((path) => {
38+
const item = asDirectoryHandle(model.getItem(path));
39+
return item !== null && item.isExpanded();
40+
})
41+
);
42+
}
43+
44+
export function setAllDirectoriesExpanded(
45+
model: FileTreeExpansionModel,
46+
directoryPaths: readonly string[],
47+
expanded: boolean,
48+
): void {
49+
for (const path of directoryPaths) {
50+
const item = asDirectoryHandle(model.getItem(path));
51+
if (item === null || item.isExpanded() === expanded) continue;
52+
if (expanded) item.expand();
53+
else item.collapse();
54+
}
55+
}

apps/web/src/components/settings/ThemeEditorPanel.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ export function ThemeEditorPanel({
308308
const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState<
309309
Record<ThemeAppearance, boolean>
310310
>({ light: false, dark: false });
311+
const [shouldRegenerateGuidedColors, setShouldRegenerateGuidedColors] = useState(false);
311312
const [error, setError] = useState<string | null>(null);
312313
const [isMinimized, setIsMinimized] = useState(false);
313314
const [roleQuery, setRoleQuery] = useState("");
@@ -401,6 +402,10 @@ export function ThemeEditorPanel({
401402
// regenerate when the guided editor produced it.
402403
setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true);
403404
setSimpleColorsDirtyByAppearance({ light: false, dark: false });
405+
// An unmanaged palette needs conversion when the user opts into the
406+
// guided editor. Merely revealing Advanced for a managed/default draft
407+
// must stay read-only until a color changes.
408+
setShouldRegenerateGuidedColors(sourceTheme !== null && sourceTheme.managed !== true);
404409
setColorsByAppearance(nextColors);
405410
setSelectedRole(null);
406411
setUsageCount(null);
@@ -504,6 +509,7 @@ export function ThemeEditorPanel({
504509
[activeAppearance]: true,
505510
}));
506511
}
512+
if (isAdvanced) setShouldRegenerateGuidedColors(true);
507513
},
508514
[activeAppearance, isAdvanced],
509515
);
@@ -746,6 +752,7 @@ export function ThemeEditorPanel({
746752
if (selectedRole && !THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)) {
747753
setSelectedRole(null);
748754
}
755+
if (!shouldRegenerateGuidedColors) return;
749756

750757
// Regenerate every appearance the theme will save, not just the visible
751758
// one, so the palettes shown after toggling match what gets saved.
@@ -765,8 +772,9 @@ export function ThemeEditorPanel({
765772
}
766773
return next;
767774
});
775+
setShouldRegenerateGuidedColors(false);
768776
},
769-
[activeAppearance, editingTheme, selectedRole],
777+
[activeAppearance, editingTheme, selectedRole, shouldRegenerateGuidedColors],
770778
);
771779

772780
const handleSubmit = () => {

apps/web/src/environmentGrouping.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,74 @@ describe("environment grouping", () => {
323323
expect(entries[1]?.group.displayName).toBe("separate");
324324
});
325325

326+
it("keeps the current environment when available and falls back otherwise", () => {
327+
const currentPrimary = makeProject({ repositoryIdentity });
328+
const currentRemote = makeProject({
329+
id: ProjectId.make("current-remote"),
330+
environmentId: remoteEnvironmentId,
331+
repositoryIdentity,
332+
});
333+
const destinationRepositoryIdentity = {
334+
canonicalKey: "github.com/example/destination",
335+
locator: {
336+
source: "git-remote" as const,
337+
remoteName: "origin",
338+
remoteUrl: "https://github.com/example/destination.git",
339+
},
340+
};
341+
const destinationPrimary = makeProject({
342+
id: ProjectId.make("destination-primary"),
343+
title: "destination",
344+
workspaceRoot: "/tmp/destination",
345+
repositoryIdentity: destinationRepositoryIdentity,
346+
});
347+
const destinationRemote = makeProject({
348+
id: ProjectId.make("destination-remote"),
349+
environmentId: remoteEnvironmentId,
350+
title: "destination",
351+
workspaceRoot: "/remote/destination",
352+
repositoryIdentity: destinationRepositoryIdentity,
353+
});
354+
const fallbackPrimary = makeProject({
355+
id: ProjectId.make("fallback-primary"),
356+
title: "fallback",
357+
workspaceRoot: "/tmp/fallback",
358+
});
359+
const groups = buildSidebarProjectSnapshots({
360+
projects: [
361+
currentPrimary,
362+
currentRemote,
363+
destinationPrimary,
364+
destinationRemote,
365+
fallbackPrimary,
366+
],
367+
settings: defaultGroupingSettings,
368+
primaryEnvironmentId,
369+
resolveEnvironmentLabel: () => null,
370+
});
371+
372+
const entries = buildSidebarProjectPickerEntries({
373+
groups,
374+
preferredProjectRef: {
375+
environmentId: remoteEnvironmentId,
376+
projectId: currentRemote.id,
377+
},
378+
});
379+
const destination = entries.find(
380+
(entry) => entry.group.projectKey === destinationRepositoryIdentity.canonicalKey,
381+
);
382+
const fallback = entries.find((entry) => entry.group.displayName === "fallback");
383+
384+
expect(destination?.targetProject).toMatchObject({
385+
environmentId: remoteEnvironmentId,
386+
id: destinationRemote.id,
387+
});
388+
expect(fallback?.targetProject).toMatchObject({
389+
environmentId: primaryEnvironmentId,
390+
id: fallbackPrimary.id,
391+
});
392+
});
393+
326394
it("keeps manual project order when building grouped sidebar entries", () => {
327395
const primary = makeProject({ repositoryIdentity });
328396
const remote = makeProject({

0 commit comments

Comments
 (0)