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
17 changes: 11 additions & 6 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { useMemo, useState } from "react";
import { Platform, useWindowDimensions } from "react-native";

import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping";
import {
pickableProjectGroups,
resolveScratchProjectScopeKey,
} from "@t3tools/client-runtime/state/project-grouping";

import { NativeStackScreenOptions } from "../../native/StackHeader";
import { useProjects } from "../../state/entities";
Expand Down Expand Up @@ -138,11 +141,13 @@ export function HomeRouteScreen() {
);
const projectFilterOptions = useMemo(
() =>
buildHomeProjectScopes({
projects,
environmentId: selectedEnvironmentId,
projectGroupingMode: listOptions.projectGroupingMode,
}).map((scope) => ({
pickableProjectGroups(
buildHomeProjectScopes({
projects,
environmentId: selectedEnvironmentId,
projectGroupingMode: listOptions.projectGroupingMode,
}),
).map((scope) => ({
key: scope.key,
label: scope.title,
})),
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type EnvironmentProject,
type EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";
Expand Down Expand Up @@ -376,7 +377,11 @@ export function HomeScreen(props: HomeScreenProps) {
})();

const v2ProjectScopeKey = props.selectedProjectKey;
const v2ScopedProjectGroup = findHomeProjectScope(projectScopes, v2ProjectScopeKey);
// An archived project's filter reads as all projects, matching the filter menu.
const v2ScopedProjectGroup = findHomeProjectScope(
pickableProjectGroups(projectScopes),
v2ProjectScopeKey,
);
const threadSearch = useThreadSearch(
searchEnvironmentIds,
props.searchQuery,
Expand Down Expand Up @@ -711,7 +716,10 @@ export function HomeScreen(props: HomeScreenProps) {
<ThreadListFilters
gutter={primaryColumn ? 8 : 0}
environments={props.environments}
projects={projectScopes.map((scope) => ({ key: scope.key, label: scope.title }))}
projects={pickableProjectGroups(projectScopes).map((scope) => ({
key: scope.key,
label: scope.title,
}))}
selectedEnvironmentId={props.selectedEnvironmentId}
selectedProjectKey={props.selectedProjectKey}
onEnvironmentChange={props.onEnvironmentChange}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/home/homeThreadList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface HomeProjectScope {
readonly representative: EnvironmentProject;
readonly projects: ReadonlyArray<EnvironmentProject>;
readonly projectRefs: ReadonlyArray<ScopedProjectRef>;
readonly pinned: boolean;
readonly archived: boolean;
}

function getProjectSortTimestamp(
Expand Down Expand Up @@ -62,6 +64,8 @@ export function buildHomeProjectScopes(input: {
representative: group.representative,
projects: group.members.map((member) => member.project),
projectRefs: group.memberProjectRefs,
pinned: group.pinned,
archived: group.archived,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};
});
}
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/features/keyboard/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,11 @@ export function CommandPalette(props: {
})),
);
}
const projectItems: CommandPaletteItem[] = projects.map((project) => ({
const openProjects = projects.filter((project) => project.archivedAt == null);
const projectItems: CommandPaletteItem[] = [
...openProjects.filter((project) => project.pinnedAt != null),
...openProjects.filter((project) => project.pinnedAt == null),
].map((project) => ({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
key: `project:${scopedProjectKey(project.environmentId, project.id)}`,
kind: "project",
project,
Expand Down
17 changes: 16 additions & 1 deletion apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,9 @@ function useCreateProject(environment: EnvironmentOption | null) {
const openNewTaskDraft = useOpenNewTaskDraft();
const navigation = useNavigation();
const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
const updateProject = useAtomCommand(projectEnvironment.update, {
label: "project unarchive",
});
const projects = useProjects();

return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => {
Expand All @@ -734,7 +737,19 @@ function useCreateProject(environment: EnvironmentOption | null) {
path: workspaceRoot,
});
if (existing) {
Dialog.alert("Project already exists", existing.title);
// Adding an archived project's folder again brings the project back.
const unarchived =
existing.archivedAt == null ||
(
await updateProject({
environmentId: existing.environmentId,
input: { projectId: existing.id, archived: false },
})
)._tag === "Success";
Dialog.alert(
"Project already exists",
unarchived ? existing.title : `${existing.title} is still archived. Try again later.`,
);
openNewTaskDraft(
{
environmentId: existing.environmentId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping";
import {
deriveProjectGroupLabel,
type ProjectGroup,
} from "@t3tools/client-runtime/state/project-grouping";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import { useNavigation } from "@react-navigation/native";
import { useRef, useState } from "react";
Expand All @@ -34,6 +37,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { SettingsScreen } from "./components/SettingsScreen";
import { SettingsSection } from "./components/SettingsSection";
import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import {
AndroidSettingsEnvironmentFilter,
SettingsEnvironmentFilterHeader,
Expand All @@ -59,6 +63,7 @@ export function SettingsProjectOverviewRouteScreen() {
};
const navigation = useNavigation();
const {
availableTargets,
selectedTargets,
projectGroups,
selectedProjectKey,
Expand Down Expand Up @@ -95,7 +100,7 @@ export function SettingsProjectOverviewRouteScreen() {
<Text className="px-2 text-base text-foreground-muted">
Choose a project with the filter above to manage its name, checkouts and removal.
</Text>
) : members.length === 0 ? (
) : !group || members.length === 0 ? (
<Text className="px-2 text-base text-foreground-muted">
This project has no checkout on the selected connected environments. Change the filter
above.
Expand All @@ -104,6 +109,8 @@ export function SettingsProjectOverviewRouteScreen() {
<ProjectOverviewContent
key={`${selectedProjectKey}:${members.map((member) => member.id).join(",")}`}
members={members}
group={group}
connectedTargets={availableTargets}
environments={selectedTargets}
hasOtherMembers={members.length < (group?.members.length ?? 0)}
isRemoving={isRemoving}
Expand All @@ -122,6 +129,9 @@ export function SettingsProjectOverviewRouteScreen() {

function ProjectOverviewContent(props: {
readonly members: readonly EnvironmentProject[];
/** Pin and archive belong to the whole project, even when the filter picks one machine. */
readonly group: ProjectGroup;
readonly connectedTargets: readonly SettingsTarget[];
readonly environments: readonly SettingsTarget[];
readonly hasOtherMembers: boolean;
readonly isRemoving: boolean;
Expand All @@ -137,10 +147,8 @@ function ProjectOverviewContent(props: {
const [draftName, setDraftName] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const threads = useThreadShells();
const removableEnvironments = useEnvironmentsWithScope(
props.members,
AuthOrchestrationOperateScope,
);
const allMembers = props.group.members.map((member) => member.project);
const removableEnvironments = useEnvironmentsWithScope(allMembers, AuthOrchestrationOperateScope);
const canRemoveGroup = props.members.every((member) =>
removableEnvironments.has(member.environmentId),
);
Expand All @@ -149,6 +157,52 @@ function ProjectOverviewContent(props: {
label: "project name update",
reportFailure: true,
});
const organizeProject = useAtomCommand(projectEnvironment.update, {
label: "project update",
reportFailure: true,
});
const [isOrganizing, setIsOrganizing] = useState(false);
const memberTargets = allMembers.map((member) =>
props.connectedTargets.find((entry) => entry.environmentId === member.environmentId),
);
const offline = memberTargets.some((target) => target === undefined);
// Older servers drop pin and archive, so their projects do not offer them.
// Offline members are checked when the write runs, so their reason can show.
const canOrganize =
allMembers.every(
(member, index) =>
memberTargets[index] === undefined || removableEnvironments.has(member.environmentId),
) &&
memberTargets.every(
(target) =>
target === undefined ||
target.serverConfig.environment.capabilities.projectOrganization === true,
);
// One machine at a time, stopping at the first failure, as web does.
const organize = async (input: { readonly pinned?: boolean; readonly archived?: boolean }) => {
setIsOrganizing(true);
for (const member of allMembers) {
if (!readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope)) {
Dialog.alert("Failed to update project", "This connection can no longer change it.");
break;
}
const result = await organizeProject({
environmentId: member.environmentId,
input: { projectId: member.id, ...input },
});
if (result._tag === "Failure") {
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Dialog.alert(
`Failed to update "${member.title}"`,
error instanceof Error ? error.message : "An error occurred.",
);
}
break;
}
}
setIsOrganizing(false);
};
const nextName = (draftName ?? displayName).trim();
const canSave = !isSaving && nextName.length > 0 && nextName !== displayName;

Expand Down Expand Up @@ -295,6 +349,34 @@ function ProjectOverviewContent(props: {
</View>
</SettingsSection>

{canOrganize ? (
<SettingsSection title="Organize">
<SettingsSwitchRow
icon="pin"
label="Pin"
subtitle={
offline
? "Connect every machine with this project to change this."
: "Pinned projects come first in project pickers."
}
value={props.group.pinned}
disabled={isOrganizing || offline}
onValueChange={(pinned) => void organize({ pinned })}
/>
<SettingsActionRow
icon="archivebox"
label={props.group.archived ? "Unarchive project" : "Archive project"}
description={
props.group.archived
? "Hidden from project pickers. Threads and files are kept."
: "Hides the project from project pickers. Threads and files are kept."
}
disabled={isOrganizing || offline}
onPress={() => void organize({ archived: !props.group.archived })}
/>
</SettingsSection>
) : null}

<SettingsSection title="Checkouts">
{props.members.map((member, index) => {
const environment = props.environments.find(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,16 @@ function TaskForm({
const tasks = useEnvironmentQuery(
serverEnvironment.scheduledTasksLive({ environmentId, input: {} }),
);
const projects = useProjects().filter((project) => project.environmentId === environmentId);
// Pinned first. The chosen project stays listed after it is archived.
const projects = useProjects()
.filter(
(project) =>
project.environmentId === environmentId &&
(project.archivedAt == null ||
project.id === draft.projectId ||
project.id === draft.task?.projectId),
)
.sort((left, right) => Number(right.pinnedAt != null) - Number(left.pinnedAt != null));
const config = useEnvironmentServerConfig(environmentId);
const modelOptions = buildModelOptions(config, null, undefined, draft.projectId);
const canOperate = useAtomValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function SettingsEnvironmentFilterHeader(props: {
},
...selectableProjectGroups.map((group) => ({
type: "action" as const,
label: group.label,
label: projectMenuLabel(group),
state: selectedProjectKey === group.key ? ("on" as const) : undefined,
onPress: () => selectProject(group.key),
})),
Expand Down Expand Up @@ -160,7 +160,7 @@ export function AndroidSettingsEnvironmentFilter() {
},
...selectableProjectGroups.map((group) => ({
id: `project:${group.key}`,
title: `Project · ${group.label}`,
title: `Project · ${projectMenuLabel(group)}`,
state: selectedProjectKey === group.key ? ("on" as const) : ("off" as const),
})),
]}
Expand Down Expand Up @@ -190,3 +190,6 @@ export function AndroidSettingsEnvironmentFilter() {
</Menu>
);
}

const projectMenuLabel = (group: { readonly label: string; readonly archived: boolean }) =>
group.archived ? `${group.label} · Archived` : group.label;
6 changes: 5 additions & 1 deletion apps/mobile/src/features/settings/scheduled-task-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export function useScheduledTaskEditor() {
(member) =>
member.project.environmentId === environmentId && member.project.id === project.id,
),
) ?? environmentProjects[0];
) ??
environmentProjects.find(
(project) => project.pinnedAt != null && project.archivedAt == null,
) ??
environmentProjects.find((project) => project.archivedAt == null);
return createDraft(
project?.id ?? null,
scheduledTaskDefaultModel(configs.get(environmentId) ?? null, project ?? null),
Expand Down
14 changes: 10 additions & 4 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as Dialog from "../../components/ui/Dialog";
import { useAtomValue } from "@effect/atom-react";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import {
CommonActions,
Expand Down Expand Up @@ -458,10 +459,15 @@ export function NewTaskDraftScreen(props: {
return;
}

const selection = resolveDraftProjectSelection(selectedProjectKey, projects, projectScopes, {
incomingShareId: props.incomingShareId,
pickerClosed: initialPickerRef.current === "open" && isFocused,
});
const selection = resolveDraftProjectSelection(
selectedProjectKey,
projects,
pickableProjectGroups(projectScopes),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
{
incomingShareId: props.incomingShareId,
pickerClosed: initialPickerRef.current === "open" && isFocused,
},
);
if (selection.kind === "preserve") {
initialPickerRef.current = null;
return;
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type StaticScreenProps,
} from "@react-navigation/native";
import { SymbolView } from "../../components/ui/AppSymbol";
import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping";
import { isScratchProject } from "@t3tools/client-runtime/state/projects";
import {
isAtomCommandInterrupted,
Expand Down Expand Up @@ -175,7 +176,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
const screenTitle = incomingShare ? "Start a task" : "Choose project";
const projectEmptyState = deriveProjectEmptyState(catalogState);
const serverConfigs = useServerConfigs();
const listScopes = projectScopes.filter(
const listScopes = pickableProjectGroups(projectScopes).filter(
(scope) =>
!scope.projects.every((project) =>
isScratchProject(project, serverConfigs.get(project.environmentId)?.scratchWorkspaceRoot),
Expand Down
Loading
Loading