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
30 changes: 23 additions & 7 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import {
CommonActions,
StackActions,
useFocusEffect,
useNavigation,
usePreventRemove,
type NavigationAction,
} from "@react-navigation/native";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
Expand Down Expand Up @@ -212,6 +214,9 @@ export function NewTaskDraftScreen(props: {
const [isCancellingShareImport, setIsCancellingShareImport] = useState(false);
const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState<string | null>(null);
const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false);
const [submitNavigationAction, setSubmitNavigationAction] = useState<NavigationAction | null>(
null,
);
const [shareImportAttempt, setShareImportAttempt] = useState(0);
const startedShareImportKeyRef = useRef<string | null>(null);
const cancellingShareImportKeyRef = useRef<string | null>(null);
Expand Down Expand Up @@ -279,12 +284,23 @@ export function NewTaskDraftScreen(props: {
voiceInput.elapsedSeconds,
);
const isVoiceInputPresented = voicePresentation.statusLabel !== null;
usePreventRemove(
const preventRemove =
(isIncomingShareTransferPending && !isProjectPickerReturnActive) ||
isCancellingShareImport ||
flow.submitting,
() => undefined,
);
isCancellingShareImport ||
flow.submitting;
usePreventRemove(preventRemove, () => undefined);
useEffect(() => {
if (preventRemove || submitNavigationAction === null) {
return;
}
// Give the guard update a frame to reach the parent sheet before navigating,
// just like the project-picker fallback below.
const frame = requestAnimationFrame(() => {
setSubmitNavigationAction(null);
(navigation.getParent() ?? navigation).dispatch(submitNavigationAction);
});
return () => cancelAnimationFrame(frame);
}, [navigation, preventRemove, submitNavigationAction]);
const hasImportedIncomingShare = Boolean(
props.incomingShareId &&
flow.draftKey &&
Expand Down Expand Up @@ -876,7 +892,7 @@ export function NewTaskDraftScreen(props: {
clearWorkspaceSelection: true,
});
}
navigation.getParent()?.goBack();
setSubmitNavigationAction(CommonActions.goBack());
return;
}

Expand Down Expand Up @@ -947,7 +963,7 @@ export function NewTaskDraftScreen(props: {
clearWorkspaceSelection: true,
});
}
navigation.dispatch(
setSubmitNavigationAction(
StackActions.replace("Thread", {
environmentId: String(result.value.environmentId),
threadId: String(result.value.threadId),
Expand Down
41 changes: 39 additions & 2 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import type {
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react";
import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RotateCw } from "lucide-react";
import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";

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

import { createFileTreeDragMentionController } from "./fileTreeDragMention";
import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
import { useProjectEntriesQuery } from "./projectFilesQueryState";

interface FileBrowserPanelProps {
Expand Down Expand Up @@ -118,6 +119,10 @@ export default function FileBrowserPanel({
);
const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const directoryPaths = useMemo(
() => entries.filter((entry) => entry.kind === "directory").map(treePath),
[entries],
);
const previousTreePathsRef = useRef<readonly string[]>([]);
const syncingSelectionRef = useRef(false);
const treeSelectionPathRef = useRef<string | null>(null);
Expand Down Expand Up @@ -249,6 +254,12 @@ export default function FileBrowserPanel({
unsafeCSS: TREE_UNSAFE_CSS,
});
const search = useFileTreeSearch(model);
const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
areAllDirectoriesExpanded(currentModel, directoryPaths),
);
const toggleAllDirectories = () => {
setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded);
};
const handleSearchValueChange = (value: string) => {
if (value.trim().length === 0) {
search.close();
Expand Down Expand Up @@ -367,6 +378,32 @@ export default function FileBrowserPanel({
onValueChange={handleSearchValueChange}
onClose={search.close}
/>
{directoryPaths.length > 0 ? (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-xs"
variant="ghost"
aria-label={
allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
}
onClick={toggleAllDirectories}
/>
}
>
{allDirectoriesExpanded ? (
<ChevronsDownUpIcon className="size-3.5" />
) : (
<ChevronsUpDownIcon className="size-3.5" />
)}
</TooltipTrigger>
<TooltipPopup>
{allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
</TooltipPopup>
</Tooltip>
) : null}
</div>
{entriesQuery.error && entriesQuery.data === null ? (
<div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/components/files/fileTreeExpansion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "@effect/vitest";

import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";

type FakeDirectoryItem = {
isDirectory: () => true;
isExpanded: () => boolean;
expand: () => void;
collapse: () => void;
};

function makeModel(expanded: Record<string, boolean>) {
const items = new Map<string, FakeDirectoryItem>();
return {
getItem: (path: string) => {
const existing = items.get(path);
if (existing !== undefined) return existing;
const item: FakeDirectoryItem = {
isDirectory: () => true,
isExpanded: () => expanded[path] ?? false,
expand: () => {
expanded[path] = true;
},
collapse: () => {
expanded[path] = false;
},
};
items.set(path, item);
return item;
},
};
}

describe("file tree expansion", () => {
it("requires at least one directory and detects whether all are expanded", () => {
const model = makeModel({ "src/": true, "test/": true });
expect(areAllDirectoriesExpanded(model, [])).toBe(false);
expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true);
expect(
areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]),
).toBe(false);
});

it("expands and collapses every directory", () => {
const expanded = { "src/": true, "test/": false };
const model = makeModel(expanded);
setAllDirectoriesExpanded(model, ["src/", "test/"], true);
expect(expanded).toEqual({ "src/": true, "test/": true });
setAllDirectoriesExpanded(model, ["src/", "test/"], false);
expect(expanded).toEqual({ "src/": false, "test/": false });
});

it("skips directories already at the requested state", () => {
const model = makeModel({ "src/": true });
const item = model.getItem("src/");
const collapse = vi.spyOn(item, "collapse");
setAllDirectoriesExpanded(model, ["src/"], true);
expect(collapse).not.toHaveBeenCalled();
});
});
55 changes: 55 additions & 0 deletions apps/web/src/components/files/fileTreeExpansion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export interface FileTreeExpansionModel {
getItem(path: string): unknown;
}

type DirectoryHandle = {
isDirectory(): boolean;
isExpanded(): boolean;
expand(): void;
collapse(): void;
};

function asDirectoryHandle(item: unknown): DirectoryHandle | null {
if (
typeof item !== "object" ||
item === null ||
!("isDirectory" in item) ||
typeof item.isDirectory !== "function" ||
!item.isDirectory() ||
!("isExpanded" in item) ||
typeof item.isExpanded !== "function" ||
!("expand" in item) ||
typeof item.expand !== "function" ||
!("collapse" in item) ||
typeof item.collapse !== "function"
) {
return null;
}
return item as DirectoryHandle;
}

export function areAllDirectoriesExpanded(
model: FileTreeExpansionModel,
directoryPaths: readonly string[],
): boolean {
return (
directoryPaths.length > 0 &&
directoryPaths.every((path) => {
const item = asDirectoryHandle(model.getItem(path));
return item !== null && item.isExpanded();
})
);
}

export function setAllDirectoriesExpanded(
model: FileTreeExpansionModel,
directoryPaths: readonly string[],
expanded: boolean,
): void {
for (const path of directoryPaths) {
const item = asDirectoryHandle(model.getItem(path));
if (item === null || item.isExpanded() === expanded) continue;
if (expanded) item.expand();
else item.collapse();
}
}
10 changes: 9 additions & 1 deletion apps/web/src/components/settings/ThemeEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ export function ThemeEditorPanel({
const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState<
Record<ThemeAppearance, boolean>
>({ light: false, dark: false });
const [shouldRegenerateGuidedColors, setShouldRegenerateGuidedColors] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isMinimized, setIsMinimized] = useState(false);
const [roleQuery, setRoleQuery] = useState("");
Expand Down Expand Up @@ -401,6 +402,10 @@ export function ThemeEditorPanel({
// regenerate when the guided editor produced it.
setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true);
setSimpleColorsDirtyByAppearance({ light: false, dark: false });
// An unmanaged palette needs conversion when the user opts into the
// guided editor. Merely revealing Advanced for a managed/default draft
// must stay read-only until a color changes.
setShouldRegenerateGuidedColors(sourceTheme !== null && sourceTheme.managed !== true);
setColorsByAppearance(nextColors);
setSelectedRole(null);
setUsageCount(null);
Expand Down Expand Up @@ -504,6 +509,7 @@ export function ThemeEditorPanel({
[activeAppearance]: true,
}));
}
if (isAdvanced) setShouldRegenerateGuidedColors(true);
},
[activeAppearance, isAdvanced],
);
Expand Down Expand Up @@ -746,6 +752,7 @@ export function ThemeEditorPanel({
if (selectedRole && !THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)) {
setSelectedRole(null);
}
if (!shouldRegenerateGuidedColors) return;

// Regenerate every appearance the theme will save, not just the visible
// one, so the palettes shown after toggling match what gets saved.
Expand All @@ -765,8 +772,9 @@ export function ThemeEditorPanel({
}
return next;
});
setShouldRegenerateGuidedColors(false);
},
[activeAppearance, editingTheme, selectedRole],
[activeAppearance, editingTheme, selectedRole, shouldRegenerateGuidedColors],
);

const handleSubmit = () => {
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/environmentGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,74 @@ describe("environment grouping", () => {
expect(entries[1]?.group.displayName).toBe("separate");
});

it("keeps the current environment when available and falls back otherwise", () => {
const currentPrimary = makeProject({ repositoryIdentity });
const currentRemote = makeProject({
id: ProjectId.make("current-remote"),
environmentId: remoteEnvironmentId,
repositoryIdentity,
});
const destinationRepositoryIdentity = {
canonicalKey: "github.com/example/destination",
locator: {
source: "git-remote" as const,
remoteName: "origin",
remoteUrl: "https://github.com/example/destination.git",
},
};
const destinationPrimary = makeProject({
id: ProjectId.make("destination-primary"),
title: "destination",
workspaceRoot: "/tmp/destination",
repositoryIdentity: destinationRepositoryIdentity,
});
const destinationRemote = makeProject({
id: ProjectId.make("destination-remote"),
environmentId: remoteEnvironmentId,
title: "destination",
workspaceRoot: "/remote/destination",
repositoryIdentity: destinationRepositoryIdentity,
});
const fallbackPrimary = makeProject({
id: ProjectId.make("fallback-primary"),
title: "fallback",
workspaceRoot: "/tmp/fallback",
});
const groups = buildSidebarProjectSnapshots({
projects: [
currentPrimary,
currentRemote,
destinationPrimary,
destinationRemote,
fallbackPrimary,
],
settings: defaultGroupingSettings,
primaryEnvironmentId,
resolveEnvironmentLabel: () => null,
});

const entries = buildSidebarProjectPickerEntries({
groups,
preferredProjectRef: {
environmentId: remoteEnvironmentId,
projectId: currentRemote.id,
},
});
const destination = entries.find(
(entry) => entry.group.projectKey === destinationRepositoryIdentity.canonicalKey,
);
const fallback = entries.find((entry) => entry.group.displayName === "fallback");

expect(destination?.targetProject).toMatchObject({
environmentId: remoteEnvironmentId,
id: destinationRemote.id,
});
expect(fallback?.targetProject).toMatchObject({
environmentId: primaryEnvironmentId,
id: fallbackPrimary.id,
});
});

it("keeps manual project order when building grouped sidebar entries", () => {
const primary = makeProject({ repositoryIdentity });
const remote = makeProject({
Expand Down
Loading
Loading