1207 lines · 46.7 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import {  isAtomCommandInterrupted,  mapAtomCommandResult,  settlePromise,  squashAtomCommandFailure,  type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import { scopeProjectRef } from "@t3tools/client-runtime/environment";import { AsyncResult } from "effect/unstable/reactivity";import {  deriveProjectGroupingOverrideKey,  selectProjectGroupingSettings,} from "../../logicalProject";import type {  ContextMenuItem,  ModelSelection,  ProviderDriverKind,  SidebarProjectGroupingMode,  T3ProjectFileScript,  ThreadEnvMode,} from "@t3tools/contracts";import { resolveEnvModeLabel } from "../BranchToolbar.logic";import { createModelSelection } from "@t3tools/shared/model";import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";import { useCanGoBack, useNavigate } from "@tanstack/react-router";import * as Cause from "effect/Cause";import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";import {  useCallback,  useEffect,  useMemo,  useRef,  useState,  type MouseEvent as ReactMouseEvent,} from "react"; import { useComposerDraftStore } from "../../composerDraftStore";import { isElectron } from "../../env";import {  useClientSettings,  useUpdateClientSettings,  usePrimarySettings,} from "../../hooks/useSettings";import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";import { shortcutLabelForCommand } from "../../keybindings";import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings";import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";import { readLocalApi } from "../../localApi";import {  buildProjectScript,  commandForProjectScript,  nextProjectScriptId,} from "../../projectScripts";import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings";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 { primaryServerProvidersAtom, 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 {  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 { SidebarInset } from "../ui/sidebar";import { stackedThreadToast, toastManager } from "../ui/toast";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import {  WorkspaceBreadcrumb,  WorkspaceBreadcrumbItem,  WorkspaceBreadcrumbSeparator,} from "../WorkspaceBreadcrumb";import { WorkspacePageHeader } from "../WorkspacePageHeader";import {  SettingResetButton,  SettingsPageContainer,  SettingsRow,  SettingsSection,} from "./settingsLayout";import {  canPickExternalProjectFavicon,  ProjectFaviconPickerDialog,} from "./ProjectFaviconPickerDialog";import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; 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 ProjectSettingsPage({ projectKey }: { projectKey: string }) {  const navigate = useNavigate();  const canGoBack = useCanGoBack();  const navigateBackWithinApp = useCallback(() => {    if (canGoBack) {      window.history.back();      return;    }    void navigate({ to: "/" });  }, [canGoBack, navigate]);   useEffect(() => {    const onKeyDown = (event: KeyboardEvent) => {      if (event.defaultPrevented) return;      if (event.key !== "Escape") return;      event.preventDefault();      const activeElement = document.activeElement;      if (activeElement instanceof HTMLElement) {        activeElement.blur();      }      navigateBackWithinApp();    };    window.addEventListener("keydown", onKeyDown);    return () => window.removeEventListener("keydown", onKeyDown);  }, [navigateBackWithinApp]);   return (    <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">      <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">        <WorkspacePageHeader electron={isElectron}>          <ProjectSettingsBreadcrumb projectKey={projectKey} />        </WorkspacePageHeader>        <ProjectSettingsPanel projectKey={projectKey} />      </div>    </SidebarInset>  );} function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) {  const groups = useSettingsProjectGroups();  const navigate = useNavigate();  const selected = groups.find((group) => group.projectKey === projectKey) ?? null;  const openProjectMenu = (event: ReactMouseEvent<HTMLButtonElement>) => {    const api = readLocalApi();    if (!api) return;     const rect = event.currentTarget.getBoundingClientRect();    const items: ContextMenuItem<string>[] = groups.map((group) => ({      id: group.projectKey,      label: group.displayName,    }));    void settlePromise(() =>      api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }),    ).then((clicked) => {      if (clicked._tag === "Failure" || clicked.value === null) return;      void navigate({        to: "/projects/$projectKey",        params: { projectKey: clicked.value },        replace: true,        hashScrollIntoView: false,      });    });  };   return (    <WorkspaceBreadcrumb ariaLabel="Project settings breadcrumb">      <WorkspaceBreadcrumbItem>Projects</WorkspaceBreadcrumbItem>      <WorkspaceBreadcrumbSeparator />      <WorkspaceBreadcrumbItem current>        {selected ? (          <button            type="button"            aria-haspopup="menu"            aria-label="Switch project"            onClick={openProjectMenu}            className="group/project-title inline-flex min-w-0 max-w-64 cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"          >            <span className="min-w-0 truncate">{selected.displayName}</span>            <ChevronDownIcon              aria-hidden              className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/project-title:opacity-100 group-focus-visible/project-title:opacity-100"            />          </button>        ) : (          <span className="truncate text-muted-foreground">Unavailable project</span>        )}      </WorkspaceBreadcrumbItem>    </WorkspaceBreadcrumb>  );} export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {  const groups = useSettingsProjectGroups();  const navigate = useNavigate();   const selected = groups.find((group) => group.projectKey === projectKey) ?? null;   // 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; memberKeys: string[] } | null>(null);  useEffect(() => {    if (!selected) return;    lastSelectionRef.current = {      key: selected.projectKey,      memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey),    };  }, [selected]);   // 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 (selected !== null) return;    const last = lastSelectionRef.current;    if (last?.key !== projectKey) return;    const successor = groups.find((group) =>      group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),    );    if (successor) {      void navigate({        to: "/projects/$projectKey",        params: { projectKey: successor.projectKey },        replace: true,        hashScrollIntoView: false,      });    }  }, [groups, navigate, projectKey, selected]);   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>    );  }  return <ProjectDetail key={selected.projectKey} group={selected} />;} function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {  const navigate = useNavigate();  const primaryEnvironmentId = usePrimaryEnvironmentId();  const settings = usePrimarySettings();  const updateClientSettings = useUpdateClientSettings();  const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);  const serverProviders = useAtomValue(primaryServerProvidersAtom);  const threads = useThreadShells();  const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });  const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });  const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {    reportFailure: false,  });  const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, {    reportFailure: false,  });  const projectNameEditedRef = useRef(false);  const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({    onCopy: ({ path }) => {      toastManager.add({ type: "success", title: "Path copied", description: path });    },    onError: (error) => {      toastManager.add(        stackedThreadToast({          type: "error",          title: "Failed to copy path",          description: error instanceof Error ? error.message : "An error occurred.",        }),      );    },  });   const representative =    group.memberProjects.find(      (member) => member.environmentId === group.environmentId && member.id === group.id,    ) ?? group.memberProjects[0]!;  const faviconPath = representative.faviconPath ?? null;  const pickProjectFavicon =    typeof window !== "undefined" &&    group.memberProjects.every(      (member) =>        member.environmentId === primaryEnvironmentId &&        canPickExternalProjectFavicon(member.workspaceRoot, navigator.platform),    )      ? window.desktopBridge?.pickProjectFavicon      : undefined;   const threadCountByMember = useMemo(() => {    const counts = new Map<string, number>();    for (const thread of threads) {      const key = `${thread.environmentId}:${thread.projectId}`;      counts.set(key, (counts.get(key) ?? 0) + 1);    }    return counts;  }, [threads]);  const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => {    if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;    const error = squashAtomCommandFailure(result);    toastManager.add(      stackedThreadToast({        type: "error",        title,        description: error instanceof Error ? error.message : "An error occurred.",      }),    );  }, []);   // Group-shared fields live on each physical project record, so a  // group-level edit fans out to every member.  const updateAllMembers = useCallback(    async (      input: Partial<{        title: string;        defaultModelSelection: ModelSelection | null;        defaultThreadEnvMode: ThreadEnvMode | null;        faviconPath: string | null;      }>,      failureTitle: string,    ): Promise<AtomCommandResult<void, unknown>> => {      for (const member of group.memberProjects) {        const result = mapAtomCommandResult(          await updateProject({            environmentId: member.environmentId,            input: { projectId: member.id, ...input },          }),          () => undefined,        );        if (result._tag === "Failure") {          // A partial fan-out is possible: earlier members already took the          // write. Name the environment so the user knows where it stopped.          reportFailure(            group.memberProjects.length > 1              ? `${failureTitle} on ${member.environmentLabel ?? "the current environment"}`              : failureTitle,            result,          );          return result;        }      }      return AsyncResult.success(undefined);    },    [group.memberProjects, reportFailure, updateProject],  );   const renameGroup = useCallback(    async (nextTitle: string, wasEdited: boolean) => {      const title = nextTitle.trim();      if (!title) {        toastManager.add({ type: "warning", title: "Project title cannot be empty" });        return;      }      if (        !projectGroupTitleNeedsUpdate(          group.memberProjects.map((member) => member.title),          title,          wasEdited,        )      ) {        return;      }      await updateAllMembers({ title }, "Failed to rename project");    },    [group.memberProjects, updateAllMembers],  );   // ----- default model -----  const storedSelection = representative.defaultModelSelection;  const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection);  const instanceEntries = useMemo(    () =>      sortProviderInstanceEntries(        applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),      ),    [serverProviders, settings],  );  const modelOptionsByInstance = useMemo(    () => getCustomModelOptionsByInstance(settings, serverProviders),    [serverProviders, settings],  );  const activeEntry = instanceEntries.find(    (entry) => entry.instanceId === resolvedSelection?.instanceId,  );  const setDefaultModel = useCallback(    (selection: ModelSelection | null) =>      void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"),    [updateAllMembers],  );   // ----- new-thread workspace mode -----  const storedEnvMode = representative.defaultThreadEnvMode ?? null;  const setDefaultThreadEnvMode = useCallback(    (mode: ThreadEnvMode | null) =>      void updateAllMembers(        { defaultThreadEnvMode: mode },        "Failed to update new-thread workspace",      ),    [updateAllMembers],  );   // ----- favicon -----  const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);  const [isSavingFavicon, setIsSavingFavicon] = useState(false);  const savingFaviconRef = useRef(false);  const setFaviconPath = useCallback(    async (faviconPath: string | null) => {      if (savingFaviconRef.current) return;      savingFaviconRef.current = true;      setIsSavingFavicon(true);      try {        await updateAllMembers({ faviconPath }, "Failed to update project icon");      } finally {        savingFaviconRef.current = false;        setIsSavingFavicon(false);      }    },    [updateAllMembers],  );   // ----- checkout selection and scripts -----  const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey);  const selectedCheckout =    group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ??    representative;  const selectedServerConfig = useAtomValue(    serverEnvironment.configValueAtom(selectedCheckout.environmentId),  );  const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;  const scripts = selectedCheckout.scripts;  const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);  // Script writes replace the whole array, so two overlapping writes computed  // from the same snapshot would drop each other's changes. One at a time.  const [isSavingScripts, setIsSavingScripts] = useState(false);  const savingScriptsRef = useRef(false);  const t3File = useT3ProjectFileState(    selectedCheckout.environmentId,    selectedCheckout.workspaceRoot,  );  // What the "Default" option resolves to while no override is set: the  // repo's t3.json value when present, otherwise the global setting.  const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode;  const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global";  const importableScripts = useMemo(    () =>      t3File.scripts.filter(        (fileScript) =>          !scripts.some(            (script) =>              script.command === fileScript.command ||              script.name.toLowerCase() === fileScript.name.toLowerCase(),

Lines 1–500 of 1207.