1483 lines · 57.3 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import {  isAtomCommandInterrupted,  mapAtomCommandResult,  settlePromise,  squashAtomCommandFailure,  type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";import { AsyncResult } from "effect/unstable/reactivity";import {  deriveProjectGroupingOverrideKey,  selectProjectGroupingSettings,} from "../../logicalProject";import {  type EnvironmentId,  type ModelSelection,  type ProjectIconOverride,  type ProjectId,  type ProjectScript,  type ResolvedKeybindingsConfig,  type ServerSettings,  type ProviderDriverKind,  type PullRequestMergeMethod,  type SidebarProjectGroupingMode,  type T3ProjectFileScript,  type ThreadEnvMode,} from "@t3tools/contracts";import { resolveEnvModeLabel } from "../BranchToolbar.logic";import { createModelSelection } from "@t3tools/shared/model";import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";import {  projectScriptsInheritDefaults,  resolveProjectScripts,} from "@t3tools/shared/projectScripts";import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";import { useNavigate } from "@tanstack/react-router";import * as Equal from "effect/Equal";import * as Cause from "effect/Cause";import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react";import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore";import {  useClientSettings,  useEnvironmentSettings,  useUpdateClientSettings,} from "../../hooks/useSettings";import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";import { ProjectActionsList } from "./ProjectActionsList";import { isElectron } from "../../env";import {  decodeProjectScriptKeybindingRule,  keybindingValueForCommand,} from "../../lib/projectScriptKeybindings";import {  buildProjectScript,  commandForProjectScript,  nextProjectScriptId,} from "../../projectScripts";import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";import { readLocalApi } from "../../localApi";import {  applyProviderInstanceSettings,  deriveProviderInstanceEntries,  resolveDefaultProviderModelSelection,  sortProviderInstanceEntries,} from "../../providerInstances";import { getCustomModelOptionsByInstance } from "../../modelSelection";import {  buildSidebarProjectSnapshots,  type SidebarProjectGroupMember,  type SidebarProjectSnapshot,} from "../../sidebarProjectGrouping";import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";import { useProjects, useThreadShells } from "../../state/entities";import { projectEnvironment } from "../../state/projects";import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";import { useAtomCommand } from "../../state/use-atom-command";import { ProviderModelPicker } from "../chat/ProviderModelPicker";import { TraitsPicker } from "../chat/TraitsPicker";import { ProjectFavicon } from "../ProjectFavicon";import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";import {  EMPTY_PROJECT_SCRIPT_INPUT,  editorRequestForScript,  ProjectScriptEditorDialog,  ScriptIcon,  type NewProjectScriptInput,  type ProjectScriptEditorRequest,} from "../projectScriptEditor";import { Button } from "../ui/button";import { Input } from "../ui/input";import {  Menu,  MenuGroup,  MenuGroupLabel,  MenuItem,  MenuPopup,  MenuSeparator,  MenuTrigger,} from "../ui/menu";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";import { Switch } from "../ui/switch";import { stackedThreadToast, toastManager } from "../ui/toast";import {  SETTINGS_PICKER_TRIGGER_CLASSNAME,  SettingResetButton,  SettingsPageContainer,  SettingsRow,  SettingsSection,} from "./settingsLayout";import {  canPickExternalProjectFavicon,  ProjectFaviconPickerDialog,} from "./ProjectFaviconPickerDialog";import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; const ProjectIconPickerDialog = lazy(() =>  import("./ProjectIconPickerDialog").then((module) => ({    default: module.ProjectIconPickerDialog,  })),); export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {  repository: "Group by repository",  repository_path: "Group by repository path",  separate: "Keep separate",}; /** Logical project groups for the settings page, sorted by display name. */export function useSettingsProjectGroups(): SidebarProjectSnapshot[] {  const projects = useProjects();  const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);  const primaryEnvironmentId = usePrimaryEnvironmentId();  const { environments } = useEnvironments();  const environmentLabelById = useMemo(    () =>      new Map(        environments.map((environment) => [environment.environmentId, environment.label] as const),      ),    [environments],  );  return useMemo(    () =>      buildSidebarProjectSnapshots({        projects,        settings: projectGroupingSettings,        primaryEnvironmentId,        resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null,      }).sort((a, b) => a.displayName.localeCompare(b.displayName)),    [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects],  );} function memberKey(member: { environmentId: string; id: string }): string {  return `${member.environmentId}:${member.id}`;} export function ProjectSettingsPanel({  projectKey,  environmentId = null,}: {  projectKey: string;  environmentId?: EnvironmentId | null;}) {  const groups = useSettingsProjectGroups();  const navigate = useNavigate();   const selected = groups.find((group) => group.projectKey === projectKey) ?? null;  const members = useMemo(    () =>      selected?.memberProjects.filter(        (member) => environmentId === null || member.environmentId === environmentId,      ) ?? [],    [selected, environmentId],  );   // Remember the members of the last rendered group so a grouping-rule change  // (which changes the group key) can follow the project to its new group.  const lastSelectionRef = useRef<{    key: string;    environmentId: EnvironmentId | null;    memberKeys: string[];  } | null>(null);  useEffect(() => {    if (!selected || members.length === 0) return;    lastSelectionRef.current = {      key: selected.projectKey,      environmentId,      memberKeys: members.map((member) => member.physicalProjectKey),    };  }, [selected, members, environmentId]);   // A grouping-rule change replaces the group key mid-visit; follow the  // project to its new key instead of parking on the not-found state.  useEffect(() => {    if (members.length > 0) return;    const last = lastSelectionRef.current;    if (last?.key !== projectKey || last.environmentId !== environmentId) return;    const successor = groups.find((group) =>      group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),    );    if (successor) {      void navigate({        to: "/settings/projects",        search: { project: successor.projectKey, machine: environmentId ?? undefined },        replace: true,        hashScrollIntoView: false,      });    }  }, [groups, navigate, projectKey, members.length, environmentId]);   if (!selected) {    return (      <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">        {groups.length === 0          ? "Add a project from the sidebar to configure it here."          : "This project is no longer available."}      </div>    );  }  if (members.length === 0)    return (      <p className="p-8 text-sm text-muted-foreground">        This project has no checkout on this machine.      </p>    );  const scopedGroup = {    ...selected,    memberProjects: members,    environmentId: members[0]!.environmentId,    id: members[0]!.id,  };  return (    <ProjectDetail      key={`${selected.projectKey}:${environmentId ?? "all"}`}      group={scopedGroup}      hasOtherMembers={members.length < selected.memberProjects.length}    />  );} function reportScriptFailure(result: AtomCommandResult<unknown, unknown>) {  if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {    const error = squashAtomCommandFailure(result);    toastManager.add({      type: "error",      title: "Failed to save project actions",      description: error instanceof Error ? error.message : "An error occurred.",    });  }  return mapAtomCommandResult(result, () => undefined);} export function useProjectScriptSettings(  targets: readonly {    environmentId: EnvironmentId;    settings: ServerSettings;    keybindings: ResolvedKeybindingsConfig;    project?: { id: ProjectId; scripts: readonly ProjectScript[] };  }[],) {  const projects = useProjects();  const [saving, setSaving] = useState(false);  const savingRef = useRef(false);  const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update");  const upsertKeybinding = useAtomCommand(    serverEnvironment.upsertKeybinding,    "action shortcut update",  );  const removeKeybinding = useAtomCommand(    serverEnvironment.removeKeybinding,    "action shortcut removal",  );   async function persist(    transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null,    scriptId?: string,    keybinding?: string | null,  ): Promise<AtomCommandResult<void, unknown>> {    if (savingRef.current || targets.length === 0) {      const message = "No available machine, or another action change is saving.";      toastManager.add({ type: "error", title: "Actions not saved", description: message });      return AsyncResult.failure(Cause.fail(new Error(message)));    }    savingRef.current = true;    setSaving(true);    try {      for (const { environmentId, settings, keybindings, project } of targets) {        const current = project          ? resolveProjectScripts(settings, project)          : settings.defaultProjectScripts;        const nextScripts = transform(current);        const effectiveScripts = nextScripts ?? settings.defaultProjectScripts;        const result = await updateSettings({          environmentId,          input: {            patch: project              ? { projectScriptOverrides: { [project.id]: nextScripts } }              : { defaultProjectScripts: nextScripts ?? [] },          },        });        if (result._tag === "Failure") return reportScriptFailure(result);        if (!isElectron) continue;        const changedIds = scriptId          ? [scriptId]          : current              .filter((script) => !effectiveScripts.some((next) => next.id === script.id))              .map((script) => script.id);        for (const id of changedIds) {          const command = commandForProjectScript(id);          const previousValue = keybindingValueForCommand(keybindings, command);          const previous = previousValue            ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command })            : null;          const next = decodeProjectScriptKeybindingRule({ keybinding, command });          const retainedElsewhere =            !nextScripts?.some((script) => script.id === id) &&            ((project && settings.defaultProjectScripts.some((script) => script.id === id)) ||              Object.entries(settings.projectScriptOverrides).some(                ([projectId, scripts]) =>                  projectId !== project?.id && scripts?.some((script) => script.id === id),              ) ||              projects.some(                (other) =>                  other.environmentId === environmentId &&                  other.id !== project?.id &&                  (project ? resolveProjectScripts(settings, other) : other.scripts).some(                    (script) => script.id === id,                  ),              ));          const bindingResult = next            ? await upsertKeybinding({                environmentId,                input:                  previous && previous.key !== next.key ? { ...next, replace: previous } : next,              })            : previous && !retainedElsewhere              ? await removeKeybinding({ environmentId, input: previous })              : null;          if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult);        }      }      return AsyncResult.success(undefined);    } finally {      savingRef.current = false;      setSaving(false);    }  }   function submit(scriptId: string | null, input: NewProjectScriptInput) {    const existingIds = [      ...projects.flatMap((project) => project.scripts.map((script) => script.id)),      ...targets.flatMap(({ settings, project }) =>        [          ...settings.defaultProjectScripts,          ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []),          ...(project?.scripts ?? []),        ].map((script) => script.id),      ),    ];    const id = scriptId ?? nextProjectScriptId(input.name, existingIds);    const next = buildProjectScript(id, input);    return persist(      (current) => {        const updated = current.map((script) =>          script.id === id            ? next            : input.runOnWorktreeCreate              ? { ...script, runOnWorktreeCreate: false }              : script,        );        return scriptId === null ? [...updated, next] : updated;      },      id,      input.keybinding,    );  }   return { saving, persist, submit };} function ProjectDetail({  group,  hasOtherMembers,}: {  group: SidebarProjectSnapshot;  hasOtherMembers: boolean;}) {  const navigate = useNavigate();  const primaryEnvironmentId = usePrimaryEnvironmentId();  const { environments } = useEnvironments();  const environmentById = useMemo(    () => new Map(environments.map((environment) => [environment.environmentId, environment])),    [environments],  );  const representative =    group.memberProjects.find(      (member) => environmentById.get(member.environmentId)?.serverConfig != null,    ) ?? group.memberProjects[0]!;  // Provider instances and model options belong to the environment that runs  // the project's threads. The hosted app has no primary environment, so  // reading them from there would show "No providers available" everywhere.  const projectSettings = useEnvironmentSettings(representative.environmentId);  const serverProviders =    useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ??    EMPTY_SERVER_PROVIDERS;  const updateClientSettings = useUpdateClientSettings();  const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);  const threads = useThreadShells();  const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });  const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting");  const [savingBrowserAccess, setSavingBrowserAccess] = useState(false);  const savingBrowserAccessRef = useRef(false);  const browserOverrides = group.memberProjects.map(    (member) =>      environmentById.get(member.environmentId)?.serverConfig?.settings        .projectAgentBrowserAccessOverrides[member.id],  );  const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id];  const browserMixed = group.memberProjects.some((member, index) => {    const settings = environmentById.get(member.environmentId)?.serverConfig?.settings;    if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false;    return (      browserOverrides[index] !== browserOverride ||      (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !==        (browserOverride ?? projectSettings.enableAgentBrowserAccess)    );  });  const setBooleanOverride = async (    key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides",    enabled: boolean | undefined,  ) => {    if (savingBrowserAccessRef.current) return;    savingBrowserAccessRef.current = true;    setSavingBrowserAccess(true);    try {      const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId));      for (const environmentId of environmentIds) {        const environment = environmentById.get(environmentId);        if (!environment?.serverConfig || environment.connection.phase !== "connected") {          toastManager.add({            type: "warning",            title: "Setting not saved",            description: `Connect ${environment?.label ?? "this machine"} and try again.`,          });          return;        }      }      if (key === "projectAutoPullOverrides" && enabled === undefined) {        const result = await updateAllMembers(          { autoPull: false },          "Failed to reset automatic pull",        );        if (result._tag === "Failure") return;      }      for (const environmentId of environmentIds) {        const overrides = Object.fromEntries(          group.memberProjects            .filter((member) => member.environmentId === environmentId)            .map((member) => [member.id, enabled ?? null]),        );        const result = await updateServerSettings({          environmentId,          input: { patch: { [key]: overrides } },        });        if (result._tag === "Failure") {          reportFailure(            `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`,            mapAtomCommandResult(result, () => undefined),          );          return;        }      }    } finally {      savingBrowserAccessRef.current = false;      setSavingBrowserAccess(false);    }  };  const setBrowserAccess = (enabled: boolean | undefined) =>    setBooleanOverride("projectAgentBrowserAccessOverrides", enabled);  const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });  const projectNameEditedRef = useRef(false);  const mergeMethodOverrides = useClientSettings(    (settings) => settings.pullRequestMergeMethodOverrides,  );  const projectMergeMethod = mergeMethodOverrides[group.projectKey];  const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => {    const nextOverrides = { ...mergeMethodOverrides };    if (method === null) {      delete nextOverrides[group.projectKey];    } else {      nextOverrides[group.projectKey] = method;    }    updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides });  };   const faviconPath = representative.faviconPath ?? null;  const projectIcon = representative.projectIcon ?? null;

Lines 1–500 of 1483.