| 1 | import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; |
| 2 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; |
| 3 | import { EnvironmentId, type T3ProjectFileScript } from "@t3tools/contracts"; |
| 4 | import { |
| 5 | isAtomCommandInterrupted, |
| 6 | squashAtomCommandFailure, |
| 7 | } from "@t3tools/client-runtime/state/runtime"; |
| 8 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; |
| 9 | import { ChevronDownIcon, PlusIcon } from "lucide-react"; |
| 10 | import { useCallback, useMemo, useState } from "react"; |
| 11 | import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; |
| 12 | import { useEnvironments } from "../../state/environments"; |
| 13 | import { |
| 14 | EMPTY_PROJECT_SCRIPT_INPUT, |
| 15 | editorRequestForScript, |
| 16 | ProjectScriptEditorDialog, |
| 17 | ScriptIcon, |
| 18 | type NewProjectScriptInput, |
| 19 | type ProjectScriptEditorRequest, |
| 20 | } from "../projectScriptEditor"; |
| 21 | import { Button } from "../ui/button"; |
| 22 | import { |
| 23 | Menu, |
| 24 | MenuGroup, |
| 25 | MenuGroupLabel, |
| 26 | MenuItem, |
| 27 | MenuPopup, |
| 28 | MenuSeparator, |
| 29 | MenuTrigger, |
| 30 | } from "../ui/menu"; |
| 31 | import { ProjectActionsList } from "./ProjectActionsList"; |
| 32 | import { useProjectScriptSettings } from "./useProjectScriptSettings"; |
| 33 | import { SettingsRow, SettingsSection } from "./settingsLayout"; |
| 34 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 35 | |
| 36 | /** |
| 37 | * A project's actions on each selected environment. Actions belong to a |
| 38 | * project, so this only renders at a project scope; the environment's |
| 39 | * `defaultProjectScripts` is the layer a project without its own list |
| 40 | * inherits. Shortcuts are environment-wide, so the same action id shares its |
| 41 | * binding on an environment. |
| 42 | */ |
| 43 | export function ProjectActionsSettings() { |
| 44 | const { scope, targets, target } = useSettingsScope(); |
| 45 | const { environments } = useEnvironments(); |
| 46 | const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; |
| 47 | const representativeConfig = target |
| 48 | ? environments.find((environment) => environment.environmentId === target.environmentId) |
| 49 | ?.serverConfig |
| 50 | : undefined; |
| 51 | const scripts = target?.settings.defaultProjectScripts ?? []; |
| 52 | const keybindings = representativeConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; |
| 53 | const mixed = targets.some( |
| 54 | (candidate) => |
| 55 | JSON.stringify(candidate.settings.defaultProjectScripts) !== JSON.stringify(scripts), |
| 56 | ); |
| 57 | const canWriteSettings = useScopedSettingsWriteAllowed(); |
| 58 | const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null); |
| 59 | const memberById = new Map( |
| 60 | isProjectScope ? scope.members.map((member) => [member.id, member]) : [], |
| 61 | ); |
| 62 | const { saving, persist, submit } = useProjectScriptSettings( |
| 63 | targets.flatMap((candidate) => { |
| 64 | const environment = environments.find( |
| 65 | (entry) => entry.environmentId === candidate.environmentId, |
| 66 | ); |
| 67 | if (!environment?.serverConfig) return []; |
| 68 | const member = candidate.projectId ? memberById.get(candidate.projectId) : undefined; |
| 69 | // An older server ignores the override record, so a project edit there |
| 70 | // would report success and vanish; such environments are left out and |
| 71 | // the legacy per-project map keeps serving them. |
| 72 | if ( |
| 73 | member && |
| 74 | environment.serverConfig.environment?.capabilities.projectSettingsOverrides !== true |
| 75 | ) { |
| 76 | return []; |
| 77 | } |
| 78 | return [ |
| 79 | { |
| 80 | environmentId: candidate.environmentId, |
| 81 | // Writes read the raw environment settings so an override entry is |
| 82 | // extended, not derived from already-resolved values. |
| 83 | settings: environment.serverConfig.settings, |
| 84 | keybindings: environment.serverConfig.keybindings, |
| 85 | ...(member ? { project: member } : {}), |
| 86 | }, |
| 87 | ]; |
| 88 | }), |
| 89 | ); |
| 90 | |
| 91 | // A project's t3.json can declare actions to import. Read it from the |
| 92 | // representative checkout; the imported action still fans out. |
| 93 | const representativeMember = target?.projectId ? memberById.get(target.projectId) : undefined; |
| 94 | const t3File = useT3ProjectFileState( |
| 95 | representativeMember?.environmentId ?? EnvironmentId.make("none"), |
| 96 | representativeMember?.workspaceRoot ?? null, |
| 97 | ); |
| 98 | const importableScripts = useMemo( |
| 99 | () => |
| 100 | t3File.scripts.filter( |
| 101 | (fileScript) => |
| 102 | !scripts.some( |
| 103 | (script) => |
| 104 | script.command === fileScript.command || |
| 105 | script.name.toLowerCase() === fileScript.name.toLowerCase(), |
| 106 | ), |
| 107 | ), |
| 108 | [scripts, t3File.scripts], |
| 109 | ); |
| 110 | const importFileScript = useCallback( |
| 111 | async (fileScript: T3ProjectFileScript) => { |
| 112 | const payload: NewProjectScriptInput = { |
| 113 | name: fileScript.name, |
| 114 | command: fileScript.command, |
| 115 | icon: fileScript.icon ?? "play", |
| 116 | runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, |
| 117 | waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, |
| 118 | runOnSettle: fileScript.runOnSettle ?? false, |
| 119 | keybinding: null, |
| 120 | previewUrl: fileScript.previewUrl ?? null, |
| 121 | autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, |
| 122 | }; |
| 123 | const result = await submit(null, payload); |
| 124 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 125 | const error = squashAtomCommandFailure(result); |
| 126 | setRequest({ |
| 127 | scriptId: null, |
| 128 | initial: payload, |
| 129 | error: error instanceof Error ? error.message : "Failed to import action.", |
| 130 | }); |
| 131 | } |
| 132 | }, |
| 133 | [submit], |
| 134 | ); |
| 135 | |
| 136 | return ( |
| 137 | <SettingsSection id="project-actions" title="Actions"> |
| 138 | <SettingsRow |
| 139 | serverScoped |
| 140 | settingKeys={["defaultProjectScripts"]} |
| 141 | mixed={mixed} |
| 142 | title="Actions" |
| 143 | description="Commands that run in this project's checkout or its worktree, with optional shortcuts." |
| 144 | onResetOverride={() => void persist(() => null)} |
| 145 | control={ |
| 146 | <div className="flex flex-wrap items-center gap-1.5"> |
| 147 | {importableScripts.length > 0 ? ( |
| 148 | <Menu> |
| 149 | <MenuTrigger |
| 150 | render={ |
| 151 | <Button |
| 152 | id="import-scripts" |
| 153 | size="xs" |
| 154 | variant="ghost" |
| 155 | disabled={saving || !canWriteSettings} |
| 156 | type="button" |
| 157 | /> |
| 158 | } |
| 159 | > |
| 160 | Import scripts |
| 161 | <ChevronDownIcon className="size-3.5" /> |
| 162 | </MenuTrigger> |
| 163 | <MenuPopup align="end"> |
| 164 | <MenuGroup> |
| 165 | <MenuGroupLabel>Import from t3.json</MenuGroupLabel> |
| 166 | <p className="px-2 pb-2 text-pretty text-sm text-muted-foreground"> |
| 167 | Add actions declared by this checkout without editing them first. |
| 168 | </p> |
| 169 | </MenuGroup> |
| 170 | <MenuSeparator /> |
| 171 | {importableScripts.map((fileScript) => ( |
| 172 | <MenuItem |
| 173 | key={`${fileScript.name} ${fileScript.command}`} |
| 174 | onClick={() => void importFileScript(fileScript)} |
| 175 | > |
| 176 | <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4 shrink-0" /> |
| 177 | <div className="min-w-0 flex-1"> |
| 178 | <div className="truncate font-medium">{fileScript.name}</div> |
| 179 | <div className="truncate font-mono text-muted-foreground"> |
| 180 | {fileScript.command} |
| 181 | </div> |
| 182 | </div> |
| 183 | </MenuItem> |
| 184 | ))} |
| 185 | </MenuPopup> |
| 186 | </Menu> |
| 187 | ) : null} |
| 188 | <Button |
| 189 | size="xs" |
| 190 | variant="outline" |
| 191 | disabled={saving || !canWriteSettings || targets.length === 0} |
| 192 | onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })} |
| 193 | > |
| 194 | <PlusIcon className="size-3.5" /> |
| 195 | Add action |
| 196 | </Button> |
| 197 | </div> |
| 198 | } |
| 199 | /> |
| 200 | {mixed ? ( |
| 201 | <SettingsRow |
| 202 | title="Different actions across environments" |
| 203 | description="Choose one environment to edit its list. Adding an action here adds it on every selected environment." |
| 204 | /> |
| 205 | ) : ( |
| 206 | <ProjectActionsList |
| 207 | scripts={scripts} |
| 208 | keybindings={keybindings} |
| 209 | disabled={saving || !canWriteSettings} |
| 210 | onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))} |
| 211 | /> |
| 212 | )} |
| 213 | {t3File.status === "invalid" ? ( |
| 214 | <SettingsRow |
| 215 | title="t3.json is invalid" |
| 216 | description="A t3.json exists in this checkout but fails to parse, so every action and icon it declares is ignored. Check the JSON syntax and icon values." |
| 217 | className="text-warning" |
| 218 | /> |
| 219 | ) : null} |
| 220 | {target && ( |
| 221 | <ProjectScriptEditorDialog |
| 222 | environmentId={target.environmentId} |
| 223 | editScope={AuthSettingsWriteScope} |
| 224 | request={request} |
| 225 | scripts={scripts} |
| 226 | onSubmit={submit} |
| 227 | onDelete={(id) => |
| 228 | void persist((current) => current.filter((script) => script.id !== id), id, null) |
| 229 | } |
| 230 | onClose={() => setRequest(null)} |
| 231 | /> |
| 232 | )} |
| 233 | </SettingsSection> |
| 234 | ); |
| 235 | } |