| author | |
| committer | |
| log | 2eda8ac6a3151b70b8ee49b438bfb6fcd835802e |
| tree | 18587141f57215c79ce868870a6c410bd8fa18dd |
| parent | 28ce9d1c1751d70c869e3de3287b96d5e28fc845 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>59 files changed, 3628 insertions(+), 315 deletions(-)
apps/mobile/src/features/projects/AddProjectScreen.test.ts created+273| ... | ... | @@ -0,0 +1,273 @@ |
| 1 | import * as NodeFS from "node:fs"; | |
| 2 | import * as NodeFSP from "node:fs/promises"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; | |
| 6 | import * as Cause from "effect/Cause"; | |
| 7 | import { AsyncResult } from "effect/reactivity"; | |
| 8 | import { isValidElement, type ReactNode } from "react"; | |
| 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 10 | ||
| 11 | const state = vi.hoisted(() => ({ | |
| 12 | scopes: new Set<string>(), | |
| 13 | otherScopes: new Set<string>(), | |
| 14 | baseDirectory: "", | |
| 15 | projects: [] as string[], | |
| 16 | createRequests: [] as { environmentId: string; workspaceRoot: string }[], | |
| 17 | })); | |
| 18 | ||
| 19 | vi.mock("react", async (importOriginal) => ({ | |
| 20 | ...(await importOriginal<typeof import("react")>()), | |
| 21 | useCallback: (callback: unknown) => callback, | |
| 22 | useMemo: (factory: () => unknown) => factory(), | |
| 23 | useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}], | |
| 24 | useRef: (current: unknown) => ({ current }), | |
| 25 | useEffect: () => {}, | |
| 26 | })); | |
| 27 | vi.mock("../../components/MaterialListRow", () => ({ MaterialListRow: "MaterialListRow" })); | |
| 28 | vi.mock("../../components/MaterialButton", () => ({ MaterialButton: "MaterialButton" })); | |
| 29 | vi.mock("../../components/ScreenScrollView", () => ({ ScreenScrollView: "ScrollView" })); | |
| 30 | vi.mock("../settings/components/SettingsScreen", () => ({ SettingsScreen: "SettingsScreen" })); | |
| 31 | vi.mock("../../components/ThemedSwitch", () => ({ ThemedSwitch: "Switch" })); | |
| 32 | vi.mock("react-native", () => ({ | |
| 33 | Platform: { OS: "ios" }, | |
| 34 | ActivityIndicator: "ActivityIndicator", | |
| 35 | Alert: { alert: () => {} }, | |
| 36 | Pressable: "Pressable", | |
| 37 | ScrollView: "ScrollView", | |
| 38 | View: "View", | |
| 39 | })); | |
| 40 | vi.mock("@react-navigation/native", () => ({ | |
| 41 | useNavigation: () => ({ dispatch: () => {} }), | |
| 42 | CommonActions: { reset: (input: unknown) => input }, | |
| 43 | StackActions: {}, | |
| 44 | })); | |
| 45 | vi.mock("react-native-safe-area-context", () => ({ | |
| 46 | useSafeAreaInsets: () => ({ bottom: 0 }), | |
| 47 | })); | |
| 48 | vi.mock("../../components/AppSymbol", () => ({ SymbolView: "SymbolView" })); | |
| 49 | vi.mock("../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" })); | |
| 50 | vi.mock("../../components/EnvironmentMachineSymbol", () => ({ | |
| 51 | EnvironmentMachineSymbol: "EnvironmentMachineSymbol", | |
| 52 | })); | |
| 53 | vi.mock("../../components/ErrorBanner", () => ({ ErrorBanner: "ErrorBanner" })); | |
| 54 | vi.mock("../../components/SourceControlIcon", () => ({ SourceControlIcon: "SourceControlIcon" })); | |
| 55 | vi.mock("../../lib/uuid", () => ({ uuidv4: () => "project" })); | |
| 56 | vi.mock("../../state/session", () => ({ | |
| 57 | useEnvironmentScope: (environmentId: string, scope: string) => | |
| 58 | (environmentId === "environment" ? state.scopes : state.otherScopes).has(scope), | |
| 59 | readEnvironmentScope: (environmentId: string, scope: string) => | |
| 60 | (environmentId === "environment" ? state.scopes : state.otherScopes).has(scope), | |
| 61 | })); | |
| 62 | vi.mock("../../state/entities", () => ({ | |
| 63 | useProjects: () => [], | |
| 64 | useServerConfigs: () => | |
| 65 | new Map([ | |
| 66 | [ | |
| 67 | "environment", | |
| 68 | { | |
| 69 | environment: { platform: { os: "linux" }, capabilities: {} }, | |
| 70 | settings: { addProjectBaseDirectory: state.baseDirectory }, | |
| 71 | }, | |
| 72 | ], | |
| 73 | [ | |
| 74 | "other-environment", | |
| 75 | { | |
| 76 | environment: { platform: { os: "linux" }, capabilities: {} }, | |
| 77 | settings: { addProjectBaseDirectory: state.baseDirectory }, | |
| 78 | }, | |
| 79 | ], | |
| 80 | ]), | |
| 81 | })); | |
| 82 | vi.mock("../../state/use-remote-environment-registry", () => ({ | |
| 83 | useRemoteEnvironmentRuntime: () => ({ connectionState: "connected" }), | |
| 84 | useRemoteConnectionStatus: () => ({ | |
| 85 | connectedEnvironments: [ | |
| 86 | { environmentId: "environment", connectionState: "connected" }, | |
| 87 | { environmentId: "other-environment", connectionState: "connected" }, | |
| 88 | ], | |
| 89 | }), | |
| 90 | useSavedRemoteConnections: () => ({ | |
| 91 | savedConnectionsById: { | |
| 92 | connection: { environmentId: "environment", environmentLabel: "Environment" }, | |
| 93 | other: { environmentId: "other-environment", environmentLabel: "Other environment" }, | |
| 94 | }, | |
| 95 | }), | |
| 96 | })); | |
| 97 | vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); | |
| 98 | vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} })); | |
| 99 | vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); | |
| 100 | vi.mock("../../state/presentation", () => ({ | |
| 101 | useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), | |
| 102 | })); | |
| 103 | vi.mock("../../state/filesystem", () => ({ filesystemEnvironment: {} })); | |
| 104 | vi.mock("../../state/sourceControl", () => ({ | |
| 105 | sourceControlEnvironment: { | |
| 106 | cloneRepository: async ({ input }: { input: { destinationPath: string } }) => { | |
| 107 | NodeFS.mkdirSync(input.destinationPath); | |
| 108 | return AsyncResult.success({ cwd: input.destinationPath }); | |
| 109 | }, | |
| 110 | }, | |
| 111 | })); | |
| 112 | vi.mock("../../state/projects", () => ({ | |
| 113 | projectEnvironment: { | |
| 114 | create: async ({ | |
| 115 | environmentId, | |
| 116 | input, | |
| 117 | }: { | |
| 118 | environmentId: string; | |
| 119 | input: { workspaceRoot: string }; | |
| 120 | }) => { | |
| 121 | state.createRequests.push({ environmentId, workspaceRoot: input.workspaceRoot }); | |
| 122 | const scopes = environmentId === "environment" ? state.scopes : state.otherScopes; | |
| 123 | if (!scopes.has(AuthOrchestrationOperateScope)) { | |
| 124 | return AsyncResult.failure(Cause.fail(new Error("Project creation denied"))); | |
| 125 | } | |
| 126 | state.projects.push(input.workspaceRoot); | |
| 127 | return AsyncResult.success(undefined); | |
| 128 | }, | |
| 129 | }, | |
| 130 | })); | |
| 131 | ||
| 132 | import { AddProjectDestinationScreen, AddProjectLocalFolderScreen } from "./AddProjectScreen"; | |
| 133 | ||
| 134 | function findAction(node: ReactNode, label: string): (() => unknown) | null { | |
| 135 | if (Array.isArray(node)) { | |
| 136 | for (const child of node) { | |
| 137 | const action = findAction(child, label); | |
| 138 | if (action) return action; | |
| 139 | } | |
| 140 | return null; | |
| 141 | } | |
| 142 | if (!isValidElement<{ label?: string; onPress?: () => unknown; children?: ReactNode }>(node)) { | |
| 143 | return null; | |
| 144 | } | |
| 145 | if (node.props.label === label) return node.props.onPress ?? null; | |
| 146 | return findAction(node.props.children, label); | |
| 147 | } | |
| 148 | ||
| 149 | function cloneAction() { | |
| 150 | const action = findAction( | |
| 151 | AddProjectDestinationScreen({ | |
| 152 | environmentId: "environment", | |
| 153 | remoteUrl: "https://example.com/repo.git", | |
| 154 | repositoryName: "repo", | |
| 155 | }), | |
| 156 | "Clone project", | |
| 157 | ); | |
| 158 | if (!action) throw new Error("Clone action missing"); | |
| 159 | return action; | |
| 160 | } | |
| 161 | ||
| 162 | function localProjectAction(environmentId = "environment") { | |
| 163 | const action = findAction(AddProjectLocalFolderScreen({ environmentId }), "Add project"); | |
| 164 | if (!action) throw new Error("Add project action missing"); | |
| 165 | return action; | |
| 166 | } | |
| 167 | ||
| 168 | describe("clone project permissions", () => { | |
| 169 | beforeEach(async () => { | |
| 170 | state.baseDirectory = await NodeFSP.mkdtemp( | |
| 171 | NodePath.join(NodeOS.tmpdir(), "t3-clone-permissions-"), | |
| 172 | ); | |
| 173 | state.scopes = new Set([AuthSourceControlWriteScope]); | |
| 174 | state.otherScopes = new Set(); | |
| 175 | state.projects = []; | |
| 176 | state.createRequests = []; | |
| 177 | }); | |
| 178 | ||
| 179 | afterEach(async () => { | |
| 180 | await NodeFSP.rm(state.baseDirectory, { recursive: true, force: true }); | |
| 181 | }); | |
| 182 | ||
| 183 | it("does not leave a clone on disk when project creation is denied", async () => { | |
| 184 | await cloneAction()(); | |
| 185 | ||
| 186 | expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false); | |
| 187 | expect(state.projects).toEqual([]); | |
| 188 | }); | |
| 189 | ||
| 190 | it("clones and registers the project when both permissions are granted", async () => { | |
| 191 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 192 | await cloneAction()(); | |
| 193 | ||
| 194 | const destination = NodePath.join(state.baseDirectory, "repo"); | |
| 195 | expect(NodeFS.existsSync(destination)).toBe(true); | |
| 196 | expect(state.projects).toEqual([destination]); | |
| 197 | }); | |
| 198 | ||
| 199 | it.each([AuthSourceControlWriteScope, AuthOrchestrationOperateScope])( | |
| 200 | "rechecks %s before a retained clone action creates a directory", | |
| 201 | async (scope) => { | |
| 202 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 203 | const submit = cloneAction(); | |
| 204 | state.scopes.delete(scope); | |
| 205 | await submit(); | |
| 206 | ||
| 207 | expect(NodeFS.existsSync(NodePath.join(state.baseDirectory, "repo"))).toBe(false); | |
| 208 | expect(state.projects).toEqual([]); | |
| 209 | }, | |
| 210 | ); | |
| 211 | }); | |
| 212 | ||
| 213 | describe("local project permissions", () => { | |
| 214 | beforeEach(() => { | |
| 215 | state.baseDirectory = "/workspace/project"; | |
| 216 | state.scopes = new Set(); | |
| 217 | state.otherScopes = new Set(); | |
| 218 | state.projects = []; | |
| 219 | state.createRequests = []; | |
| 220 | }); | |
| 221 | ||
| 222 | it.each([false, true])( | |
| 223 | "does not dispatch project creation without the current grant (revoked: %s)", | |
| 224 | async (revokeBeforeSubmit) => { | |
| 225 | if (revokeBeforeSubmit) state.scopes.add(AuthOrchestrationOperateScope); | |
| 226 | const submit = localProjectAction(); | |
| 227 | state.scopes.delete(AuthOrchestrationOperateScope); | |
| 228 | ||
| 229 | await submit(); | |
| 230 | ||
| 231 | expect(state.createRequests).toEqual([]); | |
| 232 | expect(state.projects).toEqual([]); | |
| 233 | }, | |
| 234 | ); | |
| 235 | ||
| 236 | it("adds a typed local path with only task-operation permission", async () => { | |
| 237 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 238 | ||
| 239 | await localProjectAction()(); | |
| 240 | ||
| 241 | expect(state.createRequests).toEqual([ | |
| 242 | { environmentId: "environment", workspaceRoot: state.baseDirectory }, | |
| 243 | ]); | |
| 244 | expect(state.projects).toEqual([state.baseDirectory]); | |
| 245 | }); | |
| 246 | ||
| 247 | it("uses a grant added while the local-folder form is open", async () => { | |
| 248 | const submit = localProjectAction(); | |
| 249 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 250 | ||
| 251 | await submit(); | |
| 252 | ||
| 253 | expect(state.projects).toEqual([state.baseDirectory]); | |
| 254 | }); | |
| 255 | ||
| 256 | it.each([false, true])( | |
| 257 | "uses the selected environment's grant (allowed: %s)", | |
| 258 | async (allowSelectedEnvironment) => { | |
| 259 | (allowSelectedEnvironment ? state.otherScopes : state.scopes).add( | |
| 260 | AuthOrchestrationOperateScope, | |
| 261 | ); | |
| 262 | ||
| 263 | await localProjectAction("other-environment")(); | |
| 264 | ||
| 265 | expect(state.createRequests).toEqual( | |
| 266 | allowSelectedEnvironment | |
| 267 | ? [{ environmentId: "other-environment", workspaceRoot: state.baseDirectory }] | |
| 268 | : [], | |
| 269 | ); | |
| 270 | expect(state.projects).toEqual(allowSelectedEnvironment ? [state.baseDirectory] : []); | |
| 271 | }, | |
| 272 | ); | |
| 273 | }); |
apps/mobile/src/features/projects/AddProjectScreen.tsx+72-21| ... | ... | @@ -39,6 +39,8 @@ import { |
| 39 | 39 | isWindowsPlatform, |
| 40 | 40 | } from "@t3tools/client-runtime/state/projects"; |
| 41 | 41 | import { |
| 42 | AuthOrchestrationOperateScope, | |
| 43 | AuthSourceControlWriteScope, | |
| 42 | 44 | CommandId, |
| 43 | 45 | type EnvironmentId, |
| 44 | 46 | type EnvironmentMachineKind, |
| ... | ... | @@ -59,6 +61,7 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit |
| 59 | 61 | import { filesystemEnvironment } from "../../state/filesystem"; |
| 60 | 62 | import { projectEnvironment } from "../../state/projects"; |
| 61 | 63 | import { useEnvironmentQuery } from "../../state/query"; |
| 64 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 62 | 65 | import { sourceControlEnvironment } from "../../state/sourceControl"; |
| 63 | 66 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 64 | 67 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| ... | ... | @@ -522,6 +525,15 @@ export function AddProjectSourceScreen() { |
| 522 | 525 | const navigation = useNavigation(); |
| 523 | 526 | const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = |
| 524 | 527 | useSelectedEnvironment(); |
| 528 | const canWriteSourceControl = useEnvironmentScope( | |
| 529 | selectedEnvironment?.environmentId ?? null, | |
| 530 | AuthSourceControlWriteScope, | |
| 531 | ); | |
| 532 | const canCreateProject = useEnvironmentScope( | |
| 533 | selectedEnvironment?.environmentId ?? null, | |
| 534 | AuthOrchestrationOperateScope, | |
| 535 | ); | |
| 536 | const canCloneProject = canWriteSourceControl && canCreateProject; | |
| 525 | 537 | const discoveryState = useEnvironmentQuery( |
| 526 | 538 | selectedEnvironment === null |
| 527 | 539 | ? null |
| ... | ... | @@ -610,7 +622,11 @@ export function AddProjectSourceScreen() { |
| 610 | 622 | ) : null} |
| 611 | 623 | <ListRow |
| 612 | 624 | title="Local folder" |
| 613 | subtitle="Browse a folder on disk" | |
| 625 | subtitle={ | |
| 626 | canCreateProject | |
| 627 | ? "Browse a folder on disk" | |
| 628 | : "This connection cannot add projects." | |
| 629 | } | |
| 614 | 630 | icon={ |
| 615 | 631 | <SymbolView |
| 616 | 632 | name="folder.badge.plus" |
| ... | ... | @@ -620,6 +636,7 @@ export function AddProjectSourceScreen() { |
| 620 | 636 | /> |
| 621 | 637 | } |
| 622 | 638 | isFirst={selectedEnvironment.newProjectsRoot === null} |
| 639 | disabled={!canCreateProject} | |
| 623 | 640 | onPress={() => |
| 624 | 641 | navigation.dispatch( |
| 625 | 642 | StackActions.push("AddProjectLocal", { |
| ... | ... | @@ -634,11 +651,13 @@ export function AddProjectSourceScreen() { |
| 634 | 651 | key={candidate} |
| 635 | 652 | source={candidate} |
| 636 | 653 | selectedEnvironmentId={selectedEnvironment.environmentId} |
| 637 | ready={readiness[candidate].ready} | |
| 654 | ready={canCloneProject && readiness[candidate].ready} | |
| 638 | 655 | hint={ |
| 639 | readiness[candidate].ready | |
| 640 | ? addProjectRemoteSourcePathHint(candidate) | |
| 641 | : (readiness[candidate].hint ?? "") | |
| 656 | !canCloneProject | |
| 657 | ? "This connection cannot clone projects." | |
| 658 | : readiness[candidate].ready | |
| 659 | ? addProjectRemoteSourcePathHint(candidate) | |
| 660 | : (readiness[candidate].hint ?? "") | |
| 642 | 661 | } |
| 643 | 662 | isFirst={false} |
| 644 | 663 | /> |
| ... | ... | @@ -670,7 +689,17 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 670 | 689 | |
| 671 | 690 | return useCallback( |
| 672 | 691 | async (workspaceRoot: string) => { |
| 673 | if (!environment || !canCreateProjectInEnvironment(environment.connectionState)) return; | |
| 692 | if ( | |
| 693 | !environment || | |
| 694 | !canCreateProjectInEnvironment(environment.connectionState) || | |
| 695 | !readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) | |
| 696 | ) { | |
| 697 | Alert.alert( | |
| 698 | "Project not added", | |
| 699 | `This connection cannot add projects right now. Any existing files remain at ${workspaceRoot}.`, | |
| 700 | ); | |
| 701 | return; | |
| 702 | } | |
| 674 | 703 | |
| 675 | 704 | const existing = findExistingAddProject({ |
| 676 | 705 | projects, |
| ... | ... | @@ -1159,6 +1188,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s |
| 1159 | 1188 | |
| 1160 | 1189 | export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { |
| 1161 | 1190 | const environment = useEnvironmentFromParam(props.environmentId); |
| 1191 | const canCreateProject = useEnvironmentScope( | |
| 1192 | environment?.environmentId ?? null, | |
| 1193 | AuthOrchestrationOperateScope, | |
| 1194 | ); | |
| 1162 | 1195 | const createProject = useCreateProject(environment); |
| 1163 | 1196 | const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = |
| 1164 | 1197 | useBrowsePathInput(environment); |
| ... | ... | @@ -1191,15 +1224,14 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st |
| 1191 | 1224 | {error ? <ErrorBanner message={error} /> : null} |
| 1192 | 1225 | {environment ? ( |
| 1193 | 1226 | <> |
| 1194 | <ProjectPathInput | |
| 1195 | value={pathInput} | |
| 1196 | onChangeText={setPathInput} | |
| 1197 | onSubmit={() => void submitPath()} | |
| 1198 | /> | |
| 1227 | {!canCreateProject ? ( | |
| 1228 | <ErrorBanner message="This connection cannot add projects." /> | |
| 1229 | ) : null} | |
| 1230 | <ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} /> | |
| 1199 | 1231 | <PrimaryActionButton |
| 1200 | 1232 | label="Add project" |
| 1201 | disabled={isBrowseNavigating || isSubmitting} | |
| 1202 | onPress={() => void submitPath()} | |
| 1233 | disabled={!canCreateProject || isBrowseNavigating || isSubmitting} | |
| 1234 | onPress={submitPath} | |
| 1203 | 1235 | loading={isSubmitting} |
| 1204 | 1236 | /> |
| 1205 | 1237 | <FolderBrowser |
| ... | ... | @@ -1230,6 +1262,15 @@ export function AddProjectDestinationScreen(props: { |
| 1230 | 1262 | }); |
| 1231 | 1263 | const navigation = useNavigation(); |
| 1232 | 1264 | const environment = useEnvironmentFromParam(props.environmentId); |
| 1265 | const canWriteSourceControl = useEnvironmentScope( | |
| 1266 | environment?.environmentId ?? null, | |
| 1267 | AuthSourceControlWriteScope, | |
| 1268 | ); | |
| 1269 | const canCreateProject = useEnvironmentScope( | |
| 1270 | environment?.environmentId ?? null, | |
| 1271 | AuthOrchestrationOperateScope, | |
| 1272 | ); | |
| 1273 | const canCloneProject = canWriteSourceControl && canCreateProject; | |
| 1233 | 1274 | const createProject = useCreateProject(environment); |
| 1234 | 1275 | const remoteUrl = stringParam(props.remoteUrl); |
| 1235 | 1276 | const repositoryTitle = stringParam(props.repositoryTitle); |
| ... | ... | @@ -1246,7 +1287,16 @@ export function AddProjectDestinationScreen(props: { |
| 1246 | 1287 | const [error, setError] = useState<string | null>(null); |
| 1247 | 1288 | |
| 1248 | 1289 | const submitPath = useCallback(async () => { |
| 1249 | if (!environment || !remoteUrl || isBrowseNavigating || isSubmitting) return; | |
| 1290 | if ( | |
| 1291 | !environment || | |
| 1292 | !readEnvironmentScope(environment.environmentId, AuthSourceControlWriteScope) || | |
| 1293 | !readEnvironmentScope(environment.environmentId, AuthOrchestrationOperateScope) || | |
| 1294 | !remoteUrl || | |
| 1295 | isBrowseNavigating || | |
| 1296 | isSubmitting | |
| 1297 | ) { | |
| 1298 | return; | |
| 1299 | } | |
| 1250 | 1300 | setError(null); |
| 1251 | 1301 | const resolved = resolveAddProjectPath({ |
| 1252 | 1302 | rawPath: pathInput, |
| ... | ... | @@ -1342,17 +1392,18 @@ export function AddProjectDestinationScreen(props: { |
| 1342 | 1392 | ) : null} |
| 1343 | 1393 | {environment ? ( |
| 1344 | 1394 | <> |
| 1345 | <ProjectPathInput | |
| 1346 | value={pathInput} | |
| 1347 | onChangeText={setPathInput} | |
| 1348 | onSubmit={() => void submitPath()} | |
| 1349 | /> | |
| 1395 | <ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} /> | |
| 1350 | 1396 | <PrimaryActionButton |
| 1351 | 1397 | label="Clone project" |
| 1352 | disabled={isBrowseNavigating || isSubmitting || !remoteUrl} | |
| 1353 | onPress={() => void submitPath()} | |
| 1398 | disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl} | |
| 1399 | onPress={submitPath} | |
| 1354 | 1400 | loading={isSubmitting} |
| 1355 | 1401 | /> |
| 1402 | {!canCloneProject ? ( | |
| 1403 | <Text className="text-sm text-foreground-muted"> | |
| 1404 | This connection cannot clone projects. | |
| 1405 | </Text> | |
| 1406 | ) : null} | |
| 1356 | 1407 | <FolderBrowser |
| 1357 | 1408 | environment={environment} |
| 1358 | 1409 | navigateToBrowsePath={navigateToBrowsePath} |
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+14-2| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 | import { MaterialListRow } from "../../components/MaterialListRow"; |
| 2 | 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 3 | import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; | |
| 3 | 4 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 4 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | |
| 5 | import { AuthSourceControlWriteScope, resolveEnvironmentMachineKind } from "@t3tools/contracts"; | |
| 5 | 6 | import { LegendList } from "@legendapp/list/react-native"; |
| 6 | 7 | import { |
| 7 | 8 | isAtomCommandInterrupted, |
| ... | ... | @@ -30,6 +31,7 @@ import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 30 | 31 | import { cn } from "../../lib/cn"; |
| 31 | 32 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 32 | 33 | import { useServerConfigs } from "../../state/entities"; |
| 34 | import { useEnvironmentScope } from "../../state/session"; | |
| 33 | 35 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 34 | 36 | import { vcsEnvironment } from "../../state/vcs"; |
| 35 | 37 | import { |
| ... | ... | @@ -264,6 +266,10 @@ export function NewTaskEnvironmentPickerRouteScreen() { |
| 264 | 266 | |
| 265 | 267 | export function NewTaskBranchPickerRouteScreen() { |
| 266 | 268 | const flow = useNewTaskFlow(); |
| 269 | const canWriteSourceControl = useEnvironmentScope( | |
| 270 | flow.selectedProject?.environmentId ?? null, | |
| 271 | AuthSourceControlWriteScope, | |
| 272 | ); | |
| 267 | 273 | const navigation = useNavigation(); |
| 268 | 274 | const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); |
| 269 | 275 | const [switchingBranchName, setSwitchingBranchName] = useState<string | null>(null); |
| ... | ... | @@ -291,7 +297,12 @@ export function NewTaskBranchPickerRouteScreen() { |
| 291 | 297 | |
| 292 | 298 | const selectBranch = useCallback( |
| 293 | 299 | async (branch: VcsRef) => { |
| 294 | if (selectingBranchNameRef.current !== null) { | |
| 300 | const needsCheckout = shouldCheckoutNewTaskBranch({ | |
| 301 | branchIsCurrent: branch.current, | |
| 302 | branchWorktreePath: branch.worktreePath, | |
| 303 | workspaceMode: flow.workspaceMode, | |
| 304 | }); | |
| 305 | if (selectingBranchNameRef.current !== null || (needsCheckout && !canWriteSourceControl)) { | |
| 295 | 306 | return; |
| 296 | 307 | } |
| 297 | 308 | selectingBranchNameRef.current = branch.name; |
| ... | ... | @@ -336,6 +347,7 @@ export function NewTaskBranchPickerRouteScreen() { |
| 336 | 347 | } |
| 337 | 348 | }, |
| 338 | 349 | [ |
| 350 | canWriteSourceControl, | |
| 339 | 351 | flow.selectBranch, |
| 340 | 352 | flow.selectedProject, |
| 341 | 353 | flow.setBranchQuery, |
apps/mobile/src/features/threads/ThreadGitControls.tsx+35-13| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios"; |
| 2 | 2 | import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types"; |
| 3 | 3 | import { |
| 4 | AuthSourceControlWriteScope, | |
| 4 | 5 | EnvironmentId, |
| 5 | 6 | type GitRunStackedActionResult, |
| 6 | 7 | type ProjectScript, |
| ... | ... | @@ -17,6 +18,7 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 17 | 18 | import { useCallback, useMemo } from "react"; |
| 18 | 19 | import { Alert } from "react-native"; |
| 19 | 20 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 21 | import { useEnvironmentScope } from "../../state/session"; | |
| 20 | 22 | import { |
| 21 | 23 | basename, |
| 22 | 24 | getTerminalStatusLabel, |
| ... | ... | @@ -113,6 +115,10 @@ type ThreadGitControlsProps = ThreadGitMenuProps & { |
| 113 | 115 | function useThreadGitControlModel(props: ThreadGitMenuProps) { |
| 114 | 116 | const navigation = useNavigation(); |
| 115 | 117 | const environmentId = props.environmentId; |
| 118 | const canWriteSourceControl = useEnvironmentScope( | |
| 119 | environmentId ? EnvironmentId.make(String(environmentId)) : null, | |
| 120 | AuthSourceControlWriteScope, | |
| 121 | ); | |
| 116 | 122 | const threadId = props.threadId; |
| 117 | 123 | const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; |
| 118 | 124 | |
| ... | ... | @@ -122,18 +128,24 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { |
| 122 | 128 | const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; |
| 123 | 129 | const isDefaultRef = gitStatus?.isDefaultRef ?? false; |
| 124 | 130 | |
| 125 | const quickAction = useMemo( | |
| 126 | () => | |
| 127 | isRepo | |
| 128 | ? resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote) | |
| 129 | : { | |
| 130 | label: "Git unavailable", | |
| 131 | disabled: true, | |
| 132 | kind: "show_hint" as const, | |
| 133 | hint: "This workspace is not a git repository.", | |
| 134 | }, | |
| 135 | [busy, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo], | |
| 136 | ); | |
| 131 | const quickAction = useMemo(() => { | |
| 132 | if (!isRepo) { | |
| 133 | return { | |
| 134 | label: "Git unavailable", | |
| 135 | disabled: true, | |
| 136 | kind: "show_hint" as const, | |
| 137 | hint: "This workspace is not a git repository.", | |
| 138 | }; | |
| 139 | } | |
| 140 | const action = resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote); | |
| 141 | return !canWriteSourceControl && (action.kind === "run_pull" || action.kind === "run_action") | |
| 142 | ? { | |
| 143 | ...action, | |
| 144 | disabled: true, | |
| 145 | hint: "This connection cannot change source control.", | |
| 146 | } | |
| 147 | : action; | |
| 148 | }, [busy, canWriteSourceControl, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo]); | |
| 137 | 149 | |
| 138 | 150 | const quickActionHint = quickAction.disabled |
| 139 | 151 | ? (quickAction.hint ?? "This action is unavailable.") |
| ... | ... | @@ -163,6 +175,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { |
| 163 | 175 | |
| 164 | 176 | const runActionWithPrompt = useCallback( |
| 165 | 177 | async (input: GitActionRequestInput) => { |
| 178 | if (!canWriteSourceControl) return; | |
| 166 | 179 | const confirmableAction = |
| 167 | 180 | input.action === "push" || |
| 168 | 181 | input.action === "create_pr" || |
| ... | ... | @@ -191,10 +204,19 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { |
| 191 | 204 | |
| 192 | 205 | await onRunAction(input); |
| 193 | 206 | }, |
| 194 | [environmentId, gitStatus, isDefaultRef, onRunAction, navigation, threadId], | |
| 207 | [ | |
| 208 | canWriteSourceControl, | |
| 209 | environmentId, | |
| 210 | gitStatus, | |
| 211 | isDefaultRef, | |
| 212 | onRunAction, | |
| 213 | navigation, | |
| 214 | threadId, | |
| 215 | ], | |
| 195 | 216 | ); |
| 196 | 217 | |
| 197 | 218 | const runQuickAction = useCallback(async () => { |
| 219 | if (quickAction.disabled) return; | |
| 198 | 220 | if (quickAction.kind === "open_pr") { |
| 199 | 221 | await openExistingPr(); |
| 200 | 222 | return; |
apps/mobile/src/features/threads/git/GitBranchesSheet.test.tsx created+172| ... | ... | @@ -0,0 +1,172 @@ |
| 1 | import { isValidElement, type ReactNode } from "react"; | |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | const state = vi.hoisted(() => ({ | |
| 5 | fields: [] as string[], | |
| 6 | fieldIndex: 0, | |
| 7 | canChangeThreadBranch: true, | |
| 8 | navigations: 0, | |
| 9 | result: Promise.resolve(null) as Promise<unknown>, | |
| 10 | })); | |
| 11 | ||
| 12 | vi.mock("react", async (importOriginal) => ({ | |
| 13 | ...(await importOriginal<typeof import("react")>()), | |
| 14 | useState: (initial: string) => { | |
| 15 | const index = state.fieldIndex++; | |
| 16 | state.fields[index] ??= initial; | |
| 17 | return [state.fields[index], (value: string) => (state.fields[index] = value)]; | |
| 18 | }, | |
| 19 | })); | |
| 20 | vi.mock("react-native", () => ({ | |
| 21 | Platform: { OS: "ios" }, | |
| 22 | useWindowDimensions: () => ({ height: 800, width: 400 }), | |
| 23 | Pressable: "Pressable", | |
| 24 | ScrollView: "ScrollView", | |
| 25 | View: "View", | |
| 26 | })); | |
| 27 | vi.mock("@react-navigation/native", () => ({ | |
| 28 | useNavigation: () => ({ goBack: () => state.navigations++ }), | |
| 29 | })); | |
| 30 | vi.mock("react-native-safe-area-context", () => ({ | |
| 31 | useSafeAreaInsets: () => ({ bottom: 0 }), | |
| 32 | })); | |
| 33 | vi.mock("../../../components/AndroidScreenHeader", () => ({ | |
| 34 | AndroidSheetHeader: "AndroidSheetHeader", | |
| 35 | })); | |
| 36 | vi.mock("../../../components/AppText", () => ({ AppText: "Text", AppTextInput: "TextInput" })); | |
| 37 | vi.mock("./gitSheetComponents", () => ({ SheetActionButton: "SheetActionButton" })); | |
| 38 | vi.mock("../../../state/query", () => ({ | |
| 39 | useEnvironmentQuery: () => ({ data: { refName: "main" } }), | |
| 40 | })); | |
| 41 | vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); | |
| 42 | vi.mock("../../../state/use-thread-selection", () => ({ | |
| 43 | useThreadSelection: () => ({ selectedThread: { environmentId: "environment", branch: "main" } }), | |
| 44 | })); | |
| 45 | vi.mock("../../../state/use-selected-thread-worktree", () => ({ | |
| 46 | useSelectedThreadWorktree: () => ({ | |
| 47 | selectedThreadCwd: "/repo", | |
| 48 | selectedThreadWorktreePath: null, | |
| 49 | }), | |
| 50 | })); | |
| 51 | vi.mock("../../../state/use-selected-thread-git-state", () => ({ | |
| 52 | useSelectedThreadGitState: () => ({ | |
| 53 | selectedThreadBranches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], | |
| 54 | selectedThreadBranchesLoading: false, | |
| 55 | gitOperationLabel: null, | |
| 56 | }), | |
| 57 | })); | |
| 58 | vi.mock("../../../state/use-selected-thread-git-actions", () => ({ | |
| 59 | useSelectedThreadGitActions: () => ({ | |
| 60 | canChangeThreadBranch: state.canChangeThreadBranch, | |
| 61 | onCreateSelectedThreadBranch: () => state.result, | |
| 62 | onCreateSelectedThreadWorktree: () => state.result, | |
| 63 | onCheckoutSelectedThreadBranch: () => state.result, | |
| 64 | }), | |
| 65 | })); | |
| 66 | ||
| 67 | import { GitBranchesSheet } from "./GitBranchesSheet"; | |
| 68 | ||
| 69 | type ControlProps = { | |
| 70 | label?: string; | |
| 71 | placeholder?: string; | |
| 72 | onPress?: () => unknown; | |
| 73 | onChangeText?: (value: string) => void; | |
| 74 | value?: string; | |
| 75 | children?: ReactNode; | |
| 76 | }; | |
| 77 | ||
| 78 | function findControl(node: ReactNode, label: string): ControlProps | null { | |
| 79 | if (Array.isArray(node)) { | |
| 80 | for (const child of node) { | |
| 81 | const control = findControl(child, label); | |
| 82 | if (control) return control; | |
| 83 | } | |
| 84 | return null; | |
| 85 | } | |
| 86 | if (!isValidElement<ControlProps>(node)) return null; | |
| 87 | if ( | |
| 88 | node.props.label === label || | |
| 89 | node.props.placeholder === label || | |
| 90 | (label === "checkout" && node.type === "Pressable") | |
| 91 | ) { | |
| 92 | return node.props; | |
| 93 | } | |
| 94 | return findControl(node.props.children, label); | |
| 95 | } | |
| 96 | ||
| 97 | function control(label: string) { | |
| 98 | state.fieldIndex = 0; | |
| 99 | const found = findControl( | |
| 100 | GitBranchesSheet({ route: { params: { environmentId: "environment", threadId: "thread" } } }), | |
| 101 | label, | |
| 102 | ); | |
| 103 | if (!found) throw new Error(`Missing sheet control: ${label}`); | |
| 104 | return found; | |
| 105 | } | |
| 106 | ||
| 107 | function deferred<T>() { | |
| 108 | let resolve!: (value: T) => void; | |
| 109 | const promise = new Promise<T>((complete) => { | |
| 110 | resolve = complete; | |
| 111 | }); | |
| 112 | return { promise, resolve }; | |
| 113 | } | |
| 114 | ||
| 115 | describe("branch sheet operation completion", () => { | |
| 116 | beforeEach(() => { | |
| 117 | state.fields = []; | |
| 118 | state.fieldIndex = 0; | |
| 119 | state.canChangeThreadBranch = true; | |
| 120 | state.navigations = 0; | |
| 121 | control("feature/mobile-polish").onChangeText?.("feature/branch"); | |
| 122 | control("main").onChangeText?.("release"); | |
| 123 | control("feature/mobile-thread").onChangeText?.("feature/worktree"); | |
| 124 | }); | |
| 125 | ||
| 126 | it.each(["Create & checkout", "Create worktree", "checkout"])( | |
| 127 | "keeps the sheet and its input when %s cannot complete", | |
| 128 | async (operation) => { | |
| 129 | const result = deferred<null>(); | |
| 130 | state.result = result.promise; | |
| 131 | const press = control(operation).onPress; | |
| 132 | if (!press) throw new Error("Missing operation handler"); | |
| 133 | state.canChangeThreadBranch = false; | |
| 134 | const completion = press(); | |
| 135 | result.resolve(null); | |
| 136 | await result.promise; | |
| 137 | await completion; | |
| 138 | ||
| 139 | expect(state.navigations).toBe(0); | |
| 140 | expect(control("feature/mobile-polish").value).toBe("feature/branch"); | |
| 141 | expect(control("main").value).toBe("release"); | |
| 142 | expect(control("feature/mobile-thread").value).toBe("feature/worktree"); | |
| 143 | }, | |
| 144 | ); | |
| 145 | ||
| 146 | it.each(["Create & checkout", "Create worktree", "checkout"])( | |
| 147 | "dismisses after accepted %s completion even if the source grant changes", | |
| 148 | async (operation) => { | |
| 149 | const result = deferred<unknown>(); | |
| 150 | state.result = result.promise; | |
| 151 | const press = control(operation).onPress; | |
| 152 | if (!press) throw new Error("Missing operation handler"); | |
| 153 | const completion = press(); | |
| 154 | state.canChangeThreadBranch = false; | |
| 155 | result.resolve( | |
| 156 | operation === "Create worktree" | |
| 157 | ? { worktree: { path: "/repo-worktree", refName: "feature/worktree" } } | |
| 158 | : { refName: operation === "checkout" ? "main" : "feature/branch" }, | |
| 159 | ); | |
| 160 | await result.promise; | |
| 161 | await completion; | |
| 162 | ||
| 163 | expect(state.navigations).toBe(1); | |
| 164 | expect(control("feature/mobile-polish").value).toBe( | |
| 165 | operation === "Create & checkout" ? "" : "feature/branch", | |
| 166 | ); | |
| 167 | expect(control("feature/mobile-thread").value).toBe( | |
| 168 | operation === "Create worktree" ? "" : "feature/worktree", | |
| 169 | ); | |
| 170 | }, | |
| 171 | ); | |
| 172 | }); |
apps/mobile/src/features/threads/git/GitBranchesSheet.tsx+10-5| ... | ... | @@ -30,6 +30,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 30 | 30 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 31 | 31 | const gitState = useSelectedThreadGitState(); |
| 32 | 32 | const gitActions = useSelectedThreadGitActions(); |
| 33 | const { canChangeThreadBranch } = gitActions; | |
| 33 | 34 | |
| 34 | 35 | const gitStatus = useEnvironmentQuery( |
| 35 | 36 | selectedThread !== null && selectedThreadCwd !== null |
| ... | ... | @@ -109,11 +110,12 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 109 | 110 | icon="plus" |
| 110 | 111 | label="Create & checkout" |
| 111 | 112 | tone="primary" |
| 112 | disabled={busy || newBranchName.trim().length === 0} | |
| 113 | disabled={!canChangeThreadBranch || busy || newBranchName.trim().length === 0} | |
| 113 | 114 | onPress={() => { |
| 114 | 115 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); |
| 115 | 116 | if (branch.length === 0) return; |
| 116 | void gitActions.onCreateSelectedThreadBranch(branch).then(() => { | |
| 117 | void gitActions.onCreateSelectedThreadBranch(branch).then((result) => { | |
| 118 | if (result === null) return; | |
| 117 | 119 | setNewBranchName(""); |
| 118 | 120 | navigation.goBack(); |
| 119 | 121 | }); |
| ... | ... | @@ -150,6 +152,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 150 | 152 | label="Create worktree" |
| 151 | 153 | tone="primary" |
| 152 | 154 | disabled={ |
| 155 | !canChangeThreadBranch || | |
| 153 | 156 | busy || |
| 154 | 157 | worktreeBaseBranch.trim().length === 0 || |
| 155 | 158 | worktreeBranchName.trim().length === 0 |
| ... | ... | @@ -160,7 +163,8 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 160 | 163 | if (baseBranch.length === 0 || newBranch.length === 0) return; |
| 161 | 164 | void gitActions |
| 162 | 165 | .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) |
| 163 | .then(() => { | |
| 166 | .then((result) => { | |
| 167 | if (result === null) return; | |
| 164 | 168 | setWorktreeBranchName(""); |
| 165 | 169 | navigation.goBack(); |
| 166 | 170 | }); |
| ... | ... | @@ -203,9 +207,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 203 | 207 | )} |
| 204 | 208 | accessibilityRole="button" |
| 205 | 209 | accessibilityState={{ selected: branch.current, disabled: busy || disabled }} |
| 206 | disabled={busy || disabled} | |
| 210 | disabled={!canChangeThreadBranch || busy || disabled} | |
| 207 | 211 | onPress={() => { |
| 208 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { | |
| 212 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => { | |
| 213 | if (result === null) return; | |
| 209 | 214 | navigation.goBack(); |
| 210 | 215 | }); |
| 211 | 216 | }} |
apps/mobile/src/features/threads/git/GitCommitSheet.tsx+13-3| ... | ... | @@ -30,6 +30,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 30 | 30 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 31 | 31 | const gitState = useSelectedThreadGitState(); |
| 32 | 32 | const gitActions = useSelectedThreadGitActions(); |
| 33 | const { canWriteSourceControl, canChangeThreadBranch } = gitActions; | |
| 33 | 34 | |
| 34 | 35 | const gitStatus = useEnvironmentQuery( |
| 35 | 36 | selectedThread !== null && selectedThreadCwd !== null |
| ... | ... | @@ -57,6 +58,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 57 | 58 | |
| 58 | 59 | const runCommitAction = useCallback( |
| 59 | 60 | async (featureBranch: boolean) => { |
| 61 | if (!canWriteSourceControl || (featureBranch && !canChangeThreadBranch)) return; | |
| 60 | 62 | const commitMessage = dialogCommitMessage.trim(); |
| 61 | 63 | navigation.goBack(); |
| 62 | 64 | await gitActions.onRunSelectedThreadGitAction({ |
| ... | ... | @@ -66,7 +68,15 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 66 | 68 | ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), |
| 67 | 69 | }); |
| 68 | 70 | }, |
| 69 | [allSelected, dialogCommitMessage, gitActions, navigation, selectedFiles], | |
| 71 | [ | |
| 72 | allSelected, | |
| 73 | canWriteSourceControl, | |
| 74 | canChangeThreadBranch, | |
| 75 | dialogCommitMessage, | |
| 76 | gitActions, | |
| 77 | navigation, | |
| 78 | selectedFiles, | |
| 79 | ], | |
| 70 | 80 | ); |
| 71 | 81 | |
| 72 | 82 | return ( |
| ... | ... | @@ -274,7 +284,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 274 | 284 | <SheetActionButton |
| 275 | 285 | icon="arrow.branch" |
| 276 | 286 | label="Commit on new branch" |
| 277 | disabled={noneSelected || busy} | |
| 287 | disabled={!canChangeThreadBranch || noneSelected || busy} | |
| 278 | 288 | onPress={() => void runCommitAction(true)} |
| 279 | 289 | /> |
| 280 | 290 | </View> |
| ... | ... | @@ -283,7 +293,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 283 | 293 | icon="checkmark.circle" |
| 284 | 294 | label="Commit" |
| 285 | 295 | tone="primary" |
| 286 | disabled={noneSelected || busy} | |
| 296 | disabled={!canWriteSourceControl || noneSelected || busy} | |
| 287 | 297 | onPress={() => void runCommitAction(false)} |
| 288 | 298 | /> |
| 289 | 299 | </View> |
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+17-4| ... | ... | @@ -32,6 +32,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 32 | 32 | const { height: windowHeight } = useWindowDimensions(); |
| 33 | 33 | const gitState = useSelectedThreadGitState(); |
| 34 | 34 | const gitActions = useSelectedThreadGitActions(); |
| 35 | const { canWriteSourceControl, canChangeThreadBranch } = gitActions; | |
| 35 | 36 | |
| 36 | 37 | const params = props.route.params; |
| 37 | 38 | |
| ... | ... | @@ -59,17 +60,25 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 59 | 60 | ); |
| 60 | 61 | |
| 61 | 62 | const continuePendingAction = useCallback(async () => { |
| 62 | if (!confirmAction) return; | |
| 63 | if (!canWriteSourceControl || !confirmAction) return; | |
| 63 | 64 | navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); |
| 64 | 65 | await gitActions.onRunSelectedThreadGitAction({ |
| 65 | 66 | action: confirmAction, |
| 66 | 67 | ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), |
| 67 | 68 | ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), |
| 68 | 69 | }); |
| 69 | }, [confirmAction, environmentId, gitActions, params, navigation, threadId]); | |
| 70 | }, [ | |
| 71 | canWriteSourceControl, | |
| 72 | confirmAction, | |
| 73 | environmentId, | |
| 74 | gitActions, | |
| 75 | params, | |
| 76 | navigation, | |
| 77 | threadId, | |
| 78 | ]); | |
| 70 | 79 | |
| 71 | 80 | const movePendingActionToFeatureBranch = useCallback(async () => { |
| 72 | if (!confirmAction) return; | |
| 81 | if (!canChangeThreadBranch || !confirmAction) return; | |
| 73 | 82 | navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); |
| 74 | 83 | |
| 75 | 84 | if (includesCommit) { |
| ... | ... | @@ -91,9 +100,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 91 | 100 | branch.isRemote ? Result.failVoid : Result.succeed(branch.name), |
| 92 | 101 | ), |
| 93 | 102 | ); |
| 94 | await gitActions.onCreateSelectedThreadBranch(newBranchName); | |
| 103 | const created = await gitActions.onCreateSelectedThreadBranch(newBranchName); | |
| 104 | if (created === null) return; | |
| 95 | 105 | await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); |
| 96 | 106 | }, [ |
| 107 | canChangeThreadBranch, | |
| 97 | 108 | confirmAction, |
| 98 | 109 | gitActions, |
| 99 | 110 | gitState.selectedThreadBranches, |
| ... | ... | @@ -164,11 +175,13 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 164 | 175 | <SheetActionButton |
| 165 | 176 | icon="arrow.right.circle" |
| 166 | 177 | label={copy?.continueLabel ?? "Continue"} |
| 178 | disabled={!canWriteSourceControl} | |
| 167 | 179 | onPress={() => void continuePendingAction()} |
| 168 | 180 | /> |
| 169 | 181 | <SheetActionButton |
| 170 | 182 | icon="arrow.branch" |
| 171 | 183 | label="Feature branch & continue" |
| 184 | disabled={!canChangeThreadBranch} | |
| 172 | 185 | tone="primary" |
| 173 | 186 | onPress={() => void movePendingActionToFeatureBranch()} |
| 174 | 187 | /> |
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+46-13| ... | ... | @@ -87,6 +87,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 87 | 87 | ); |
| 88 | 88 | const gitState = useSelectedThreadGitState(); |
| 89 | 89 | const gitActions = useSelectedThreadGitActions(); |
| 90 | const { canWriteSourceControl, canChangeThreadBranch } = gitActions; | |
| 90 | 91 | const theme = useUniwindTheme(); |
| 91 | 92 | const foregroundColor = theme["--color-foreground"]; |
| 92 | 93 | const sheetColor = theme["--color-sheet"]; |
| ... | ... | @@ -117,15 +118,21 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 117 | 118 | const sheetMenuItems = useMemo( |
| 118 | 119 | () => |
| 119 | 120 | menuItems.map((item) => ({ |
| 120 | item, | |
| 121 | disabledReason: getGitActionDisabledReason({ | |
| 122 | item, | |
| 123 | gitStatus: gitStatus.data, | |
| 124 | isBusy: busy, | |
| 125 | hasOriginRemote: hasPrimaryRemote, | |
| 126 | }), | |
| 121 | item: { | |
| 122 | ...item, | |
| 123 | disabled: item.disabled || (!canWriteSourceControl && item.kind !== "open_pr"), | |
| 124 | }, | |
| 125 | disabledReason: | |
| 126 | !canWriteSourceControl && item.kind !== "open_pr" | |
| 127 | ? "This connection cannot change source control." | |
| 128 | : getGitActionDisabledReason({ | |
| 129 | item, | |
| 130 | gitStatus: gitStatus.data, | |
| 131 | isBusy: busy, | |
| 132 | hasOriginRemote: hasPrimaryRemote, | |
| 133 | }), | |
| 127 | 134 | })), |
| 128 | [busy, gitStatus.data, hasPrimaryRemote, menuItems], | |
| 135 | [busy, canWriteSourceControl, gitStatus.data, hasPrimaryRemote, menuItems], | |
| 129 | 136 | ); |
| 130 | 137 | |
| 131 | 138 | useEffect(() => { |
| ... | ... | @@ -145,6 +152,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 145 | 152 | |
| 146 | 153 | const runActionWithPrompt = useCallback( |
| 147 | 154 | async (input: GitActionRequestInput) => { |
| 155 | if (!canWriteSourceControl) return; | |
| 148 | 156 | const confirmableAction = |
| 149 | 157 | input.action === "push" || |
| 150 | 158 | input.action === "create_pr" || |
| ... | ... | @@ -176,7 +184,16 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 176 | 184 | } |
| 177 | 185 | await gitActions.onRunSelectedThreadGitAction(input); |
| 178 | 186 | }, |
| 179 | [environmentId, gitActions, gitStatus.data, isDefaultRef, isInspector, navigation, threadId], | |
| 187 | [ | |
| 188 | canWriteSourceControl, | |
| 189 | environmentId, | |
| 190 | gitActions, | |
| 191 | gitStatus.data, | |
| 192 | isDefaultRef, | |
| 193 | isInspector, | |
| 194 | navigation, | |
| 195 | threadId, | |
| 196 | ], | |
| 180 | 197 | ); |
| 181 | 198 | |
| 182 | 199 | const onPressMenuItem = useCallback( |
| ... | ... | @@ -186,6 +203,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 186 | 203 | await openExistingPr(); |
| 187 | 204 | return; |
| 188 | 205 | } |
| 206 | if (!canWriteSourceControl) return; | |
| 189 | 207 | if (item.dialogAction === "commit") { |
| 190 | 208 | navigation.navigate("GitCommit", { |
| 191 | 209 | environmentId: String(environmentId), |
| ... | ... | @@ -201,7 +219,14 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 201 | 219 | await runActionWithPrompt({ action: "create_pr" }); |
| 202 | 220 | } |
| 203 | 221 | }, |
| 204 | [environmentId, openExistingPr, navigation, runActionWithPrompt, threadId], | |
| 222 | [ | |
| 223 | canWriteSourceControl, | |
| 224 | environmentId, | |
| 225 | openExistingPr, | |
| 226 | navigation, | |
| 227 | runActionWithPrompt, | |
| 228 | threadId, | |
| 229 | ], | |
| 205 | 230 | ); |
| 206 | 231 | |
| 207 | 232 | // Status facts live on the relevant rows instead of crowding the header |
| ... | ... | @@ -284,8 +309,12 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 284 | 309 | <SheetListRow |
| 285 | 310 | icon="arrow.down.circle" |
| 286 | 311 | title="Pull latest" |
| 287 | subtitle={`${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`} | |
| 288 | disabled={busy || !isRepo} | |
| 312 | subtitle={ | |
| 313 | canWriteSourceControl | |
| 314 | ? `${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream` | |
| 315 | : "This connection cannot change source control." | |
| 316 | } | |
| 317 | disabled={!canWriteSourceControl || busy || !isRepo} | |
| 289 | 318 | onPress={() => void gitActions.onPullSelectedThreadBranch()} |
| 290 | 319 | /> |
| 291 | 320 | </> |
| ... | ... | @@ -309,7 +338,11 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 309 | 338 | <SheetListRow |
| 310 | 339 | icon="point.topleft.down.curvedto.point.bottomright.up" |
| 311 | 340 | title="Branches & worktrees" |
| 312 | subtitle="Switch branch, create branch, or move to a worktree" | |
| 341 | subtitle={ | |
| 342 | canChangeThreadBranch | |
| 343 | ? "Switch branch, create branch, or move to a worktree" | |
| 344 | : "View branches and worktrees" | |
| 345 | } | |
| 313 | 346 | disabled={busy || !isRepo} |
| 314 | 347 | onPress={() => |
| 315 | 348 | navigation.navigate("GitBranches", { |
apps/mobile/src/state/use-selected-thread-git-actions.test.ts created+283| ... | ... | @@ -0,0 +1,283 @@ |
| 1 | import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; | |
| 2 | import * as Cause from "effect/Cause"; | |
| 3 | import { AsyncResult } from "effect/reactivity"; | |
| 4 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | const state = vi.hoisted(() => ({ | |
| 7 | scopes: new Set<string>(), | |
| 8 | primaryScopes: new Set<string>(), | |
| 9 | branch: "main", | |
| 10 | worktrees: [] as string[], | |
| 11 | thread: { | |
| 12 | id: "thread", | |
| 13 | environmentId: "environment", | |
| 14 | branch: "main", | |
| 15 | worktreePath: null as string | null, | |
| 16 | }, | |
| 17 | commits: 0, | |
| 18 | pushes: 0, | |
| 19 | metadataRequests: [] as { environmentId: string; branch: string }[], | |
| 20 | results: [] as { type: string; description?: string }[], | |
| 21 | afterGitAction: undefined as (() => void) | undefined, | |
| 22 | statusRefreshes: 0, | |
| 23 | })); | |
| 24 | ||
| 25 | vi.mock("react", () => ({ | |
| 26 | useCallback: (callback: unknown) => callback, | |
| 27 | useMemo: (factory: () => unknown) => factory(), | |
| 28 | useEffect: () => {}, | |
| 29 | })); | |
| 30 | vi.mock("./session", () => ({ | |
| 31 | useEnvironmentScope: (environmentId: unknown, scope: string) => | |
| 32 | (environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope), | |
| 33 | readEnvironmentScope: (environmentId: unknown, scope: string) => | |
| 34 | (environmentId === state.thread.environmentId ? state.scopes : state.primaryScopes).has(scope), | |
| 35 | })); | |
| 36 | vi.mock("./use-thread-selection", () => ({ | |
| 37 | useThreadSelection: () => ({ | |
| 38 | selectedThread: state.thread, | |
| 39 | selectedThreadProject: { workspaceRoot: "/repo" }, | |
| 40 | }), | |
| 41 | })); | |
| 42 | vi.mock("./use-selected-thread-worktree", () => ({ | |
| 43 | useSelectedThreadWorktree: () => ({ | |
| 44 | selectedThreadCwd: "/repo", | |
| 45 | selectedThreadWorktreePath: null, | |
| 46 | }), | |
| 47 | })); | |
| 48 | vi.mock("./queries", () => ({ | |
| 49 | useBranches: () => ({ data: { refs: [] }, refresh: () => {} }), | |
| 50 | })); | |
| 51 | vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); | |
| 52 | vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); | |
| 53 | vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: () => {} })); | |
| 54 | vi.mock("./use-vcs-action-state", () => ({ | |
| 55 | showGitActionResult: (result: { type: string; description?: string }) => | |
| 56 | state.results.push(result), | |
| 57 | })); | |
| 58 | vi.mock("../lib/uuid", () => ({ uuidv4: () => "action" })); | |
| 59 | vi.mock("./threads", () => ({ | |
| 60 | threadEnvironment: { | |
| 61 | updateMetadata: async ({ | |
| 62 | environmentId, | |
| 63 | input, | |
| 64 | }: { | |
| 65 | environmentId: string; | |
| 66 | input: { branch: string; worktreePath: string | null }; | |
| 67 | }) => { | |
| 68 | state.metadataRequests.push({ environmentId, branch: input.branch }); | |
| 69 | if (!state.scopes.has(AuthOrchestrationOperateScope)) { | |
| 70 | return AsyncResult.failure(Cause.fail(new Error("Task operation denied"))); | |
| 71 | } | |
| 72 | Object.assign(state.thread, input); | |
| 73 | return AsyncResult.success(undefined); | |
| 74 | }, | |
| 75 | }, | |
| 76 | })); | |
| 77 | vi.mock("./vcs", () => ({ | |
| 78 | vcsEnvironment: { | |
| 79 | refreshStatus: async () => { | |
| 80 | state.statusRefreshes += 1; | |
| 81 | return AsyncResult.success({ refName: state.branch }); | |
| 82 | }, | |
| 83 | switchRef: async ({ input }: { input: { refName: string } }) => { | |
| 84 | state.branch = input.refName; | |
| 85 | state.afterGitAction?.(); | |
| 86 | return AsyncResult.success({ refName: state.branch }); | |
| 87 | }, | |
| 88 | createRef: async ({ input }: { input: { refName: string } }) => { | |
| 89 | state.branch = input.refName; | |
| 90 | state.afterGitAction?.(); | |
| 91 | return AsyncResult.success({ refName: state.branch }); | |
| 92 | }, | |
| 93 | createWorktree: async ({ input }: { input: { newRefName: string } }) => { | |
| 94 | state.worktrees.push("/repo-worktree"); | |
| 95 | state.afterGitAction?.(); | |
| 96 | return AsyncResult.success({ | |
| 97 | worktree: { path: "/repo-worktree", refName: input.newRefName }, | |
| 98 | }); | |
| 99 | }, | |
| 100 | pull: async () => AsyncResult.success({ status: "pulled", refName: state.branch }), | |
| 101 | }, | |
| 102 | vcsActionManager: { | |
| 103 | track: (_registry: unknown, _target: unknown, _operation: unknown, run: () => unknown) => run(), | |
| 104 | runStackedAction: () => async (input: { action: string; featureBranch?: boolean }) => { | |
| 105 | if (input.featureBranch) state.branch = "feature"; | |
| 106 | if (input.action === "commit") state.commits += 1; | |
| 107 | if (input.action === "push") state.pushes += 1; | |
| 108 | state.afterGitAction?.(); | |
| 109 | return AsyncResult.success({ | |
| 110 | branch: input.featureBranch | |
| 111 | ? { status: "created", name: "feature" } | |
| 112 | : { status: "skipped_not_requested" }, | |
| 113 | toast: { title: "Done", description: "Done", cta: { kind: "none" } }, | |
| 114 | }); | |
| 115 | }, | |
| 116 | }, | |
| 117 | })); | |
| 118 | ||
| 119 | import { useSelectedThreadGitActions } from "./use-selected-thread-git-actions"; | |
| 120 | ||
| 121 | describe("thread Git mutation permissions", () => { | |
| 122 | beforeEach(() => { | |
| 123 | state.scopes = new Set([AuthSourceControlWriteScope]); | |
| 124 | state.primaryScopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]); | |
| 125 | state.branch = "main"; | |
| 126 | state.worktrees = []; | |
| 127 | state.thread.branch = "main"; | |
| 128 | state.thread.worktreePath = null; | |
| 129 | state.commits = 0; | |
| 130 | state.pushes = 0; | |
| 131 | state.metadataRequests = []; | |
| 132 | state.results = []; | |
| 133 | state.afterGitAction = undefined; | |
| 134 | state.statusRefreshes = 0; | |
| 135 | }); | |
| 136 | ||
| 137 | it.each([false, true])( | |
| 138 | "requires task permission before creating and attaching a worktree: %s", | |
| 139 | async (canOperate) => { | |
| 140 | if (canOperate) state.scopes.add(AuthOrchestrationOperateScope); | |
| 141 | const actions = useSelectedThreadGitActions(); | |
| 142 | const result = await actions.onCreateSelectedThreadWorktree({ | |
| 143 | baseBranch: "main", | |
| 144 | newBranch: "feature/task", | |
| 145 | }); | |
| 146 | expect(result).toEqual( | |
| 147 | canOperate ? { worktree: { path: "/repo-worktree", refName: "feature/task" } } : null, | |
| 148 | ); | |
| 149 | expect(state.worktrees).toEqual(canOperate ? ["/repo-worktree"] : []); | |
| 150 | expect(state.thread.worktreePath).toBe(canOperate ? "/repo-worktree" : null); | |
| 151 | expect(state.thread.branch).toBe(canOperate ? "feature/task" : "main"); | |
| 152 | }, | |
| 153 | ); | |
| 154 | ||
| 155 | it.each(["create", "checkout", "commit on new branch"] as const)( | |
| 156 | "requires task permission before %s", | |
| 157 | async (operation) => { | |
| 158 | const actions = useSelectedThreadGitActions(); | |
| 159 | if (operation === "create") { | |
| 160 | expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull(); | |
| 161 | } | |
| 162 | if (operation === "checkout") | |
| 163 | expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull(); | |
| 164 | if (operation === "commit on new branch") | |
| 165 | await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true }); | |
| 166 | expect(state.branch).toBe("main"); | |
| 167 | expect(state.thread.branch).toBe("main"); | |
| 168 | expect(state.commits).toBe(0); | |
| 169 | }, | |
| 170 | ); | |
| 171 | ||
| 172 | it("rechecks task permission when a retained menu callback runs", async () => { | |
| 173 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 174 | const actions = useSelectedThreadGitActions(); | |
| 175 | state.scopes.delete(AuthOrchestrationOperateScope); | |
| 176 | expect( | |
| 177 | await actions.onCreateSelectedThreadWorktree({ | |
| 178 | baseBranch: "main", | |
| 179 | newBranch: "feature/task", | |
| 180 | }), | |
| 181 | ).toBeNull(); | |
| 182 | expect(state.worktrees).toEqual([]); | |
| 183 | expect(state.thread.worktreePath).toBeNull(); | |
| 184 | }); | |
| 185 | ||
| 186 | it("keeps ordinary commits and pushes available without task permission", async () => { | |
| 187 | const actions = useSelectedThreadGitActions(); | |
| 188 | await actions.onRunSelectedThreadGitAction({ action: "commit" }); | |
| 189 | await actions.onRunSelectedThreadGitAction({ action: "push" }); | |
| 190 | expect(state.commits).toBe(1); | |
| 191 | expect(state.pushes).toBe(1); | |
| 192 | expect(state.thread.branch).toBe("main"); | |
| 193 | expect(state.metadataRequests).toEqual([]); | |
| 194 | }); | |
| 195 | ||
| 196 | it.each(["create", "checkout", "worktree", "commit"] as const)( | |
| 197 | "does not send thread metadata after task permission is revoked during %s", | |
| 198 | async (operation) => { | |
| 199 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 200 | state.afterGitAction = () => state.scopes.delete(AuthOrchestrationOperateScope); | |
| 201 | const actions = useSelectedThreadGitActions(); | |
| 202 | if (operation === "create") | |
| 203 | expect(await actions.onCreateSelectedThreadBranch("feature")).toBeNull(); | |
| 204 | if (operation === "checkout") | |
| 205 | expect(await actions.onCheckoutSelectedThreadBranch("feature")).toBeNull(); | |
| 206 | if (operation === "worktree") | |
| 207 | expect( | |
| 208 | await actions.onCreateSelectedThreadWorktree({ | |
| 209 | baseBranch: "main", | |
| 210 | newBranch: "feature", | |
| 211 | }), | |
| 212 | ).toBeNull(); | |
| 213 | if (operation === "commit") | |
| 214 | expect( | |
| 215 | await actions.onRunSelectedThreadGitAction({ action: "commit", featureBranch: true }), | |
| 216 | ).toBeNull(); | |
| 217 | if (operation === "worktree") expect(state.worktrees).toEqual(["/repo-worktree"]); | |
| 218 | else expect(state.branch).toBe("feature"); | |
| 219 | expect(state.thread.branch).toBe("main"); | |
| 220 | expect(state.thread.worktreePath).toBeNull(); | |
| 221 | expect(state.metadataRequests).toEqual([]); | |
| 222 | expect(state.results.map((result) => result.type)).toEqual(["error"]); | |
| 223 | }, | |
| 224 | ); | |
| 225 | ||
| 226 | it("refreshes the worktree status when the metadata update is denied after checkout", async () => { | |
| 227 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 228 | // Git already switched the branch, so the sheet must show it even though the | |
| 229 | // thread record could not be updated. | |
| 230 | state.afterGitAction = () => state.scopes.delete(AuthOrchestrationOperateScope); | |
| 231 | expect( | |
| 232 | await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("feature"), | |
| 233 | ).toBeNull(); | |
| 234 | expect(state.branch).toBe("feature"); | |
| 235 | expect(state.statusRefreshes).toBe(1); | |
| 236 | }); | |
| 237 | ||
| 238 | it("uses a newly granted task permission for a retained branch callback", async () => { | |
| 239 | state.primaryScopes.clear(); | |
| 240 | const actions = useSelectedThreadGitActions(); | |
| 241 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 242 | await actions.onCreateSelectedThreadBranch("feature"); | |
| 243 | expect(state.thread.branch).toBe("feature"); | |
| 244 | expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); | |
| 245 | }); | |
| 246 | ||
| 247 | it.each(["create", "checkout", "worktree"] as const)( | |
| 248 | "returns the accepted %s result when only source-control permission is revoked after Git", | |
| 249 | async (operation) => { | |
| 250 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 251 | state.afterGitAction = () => state.scopes.delete(AuthSourceControlWriteScope); | |
| 252 | const actions = useSelectedThreadGitActions(); | |
| 253 | const result = | |
| 254 | operation === "create" | |
| 255 | ? await actions.onCreateSelectedThreadBranch("feature/task") | |
| 256 | : operation === "checkout" | |
| 257 | ? await actions.onCheckoutSelectedThreadBranch("feature/task") | |
| 258 | : await actions.onCreateSelectedThreadWorktree({ | |
| 259 | baseBranch: "main", | |
| 260 | newBranch: "feature/task", | |
| 261 | }); | |
| 262 | expect(result).toEqual( | |
| 263 | operation === "worktree" | |
| 264 | ? { worktree: { path: "/repo-worktree", refName: "feature/task" } } | |
| 265 | : { refName: "feature/task" }, | |
| 266 | ); | |
| 267 | expect(state.thread.branch).toBe("feature/task"); | |
| 268 | expect(state.metadataRequests).toHaveLength(1); | |
| 269 | expect(state.results).toEqual([]); | |
| 270 | }, | |
| 271 | ); | |
| 272 | ||
| 273 | it("returns a successful checkout when the requested branch is already selected", async () => { | |
| 274 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 275 | ||
| 276 | expect(await useSelectedThreadGitActions().onCheckoutSelectedThreadBranch("main")).toEqual({ | |
| 277 | refName: "main", | |
| 278 | }); | |
| 279 | expect(state.branch).toBe("main"); | |
| 280 | expect(state.thread.branch).toBe("main"); | |
| 281 | expect(state.results).toEqual([]); | |
| 282 | }); | |
| 283 | }); |
apps/mobile/src/state/use-selected-thread-git-actions.ts+60-22| ... | ... | @@ -7,7 +7,12 @@ import { |
| 7 | 7 | type VcsActionOperation, |
| 8 | 8 | type VcsRef, |
| 9 | 9 | } from "@t3tools/client-runtime/state/vcs"; |
| 10 | import type { GitRunStackedActionResult } from "@t3tools/contracts"; | |
| 10 | import { | |
| 11 | AuthOrchestrationOperateScope, | |
| 12 | AuthSourceControlWriteScope, | |
| 13 | EnvironmentAuthorizationError, | |
| 14 | type GitRunStackedActionResult, | |
| 15 | } from "@t3tools/contracts"; | |
| 11 | 16 | import { |
| 12 | 17 | dedupeRemoteBranchesWithLocalMatches, |
| 13 | 18 | sanitizeFeatureBranchName, |
| ... | ... | @@ -20,6 +25,7 @@ import { threadEnvironment } from "../state/threads"; |
| 20 | 25 | import { vcsActionManager, vcsEnvironment } from "../state/vcs"; |
| 21 | 26 | import { uuidv4 } from "../lib/uuid"; |
| 22 | 27 | import { appAtomRegistry } from "./atom-registry"; |
| 28 | import { readEnvironmentScope, useEnvironmentScope } from "./session"; | |
| 23 | 29 | import { setPendingConnectionError } from "./use-remote-environment-registry"; |
| 24 | 30 | import { useAtomCommand } from "./use-atom-command"; |
| 25 | 31 | import { showGitActionResult } from "./use-vcs-action-state"; |
| ... | ... | @@ -36,6 +42,15 @@ export function useSelectedThreadGitActions() { |
| 36 | 42 | const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); |
| 37 | 43 | const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); |
| 38 | 44 | const { selectedThread, selectedThreadProject } = useThreadSelection(); |
| 45 | const canWriteSourceControl = useEnvironmentScope( | |
| 46 | selectedThread?.environmentId ?? null, | |
| 47 | AuthSourceControlWriteScope, | |
| 48 | ); | |
| 49 | const canOperateThread = useEnvironmentScope( | |
| 50 | selectedThread?.environmentId ?? null, | |
| 51 | AuthOrchestrationOperateScope, | |
| 52 | ); | |
| 53 | const canChangeThreadBranch = canWriteSourceControl && canOperateThread; | |
| 39 | 54 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 40 | 55 | const runStackedAction = useAtomCommand( |
| 41 | 56 | vcsActionManager.runStackedAction({ |
| ... | ... | @@ -63,6 +78,16 @@ export function useSelectedThreadGitActions() { |
| 63 | 78 | readonly worktreePath?: string | null; |
| 64 | 79 | }, |
| 65 | 80 | ) => { |
| 81 | if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) { | |
| 82 | return AsyncResult.failure<never, EnvironmentAuthorizationError>( | |
| 83 | Cause.fail( | |
| 84 | new EnvironmentAuthorizationError({ | |
| 85 | requiredScope: AuthOrchestrationOperateScope, | |
| 86 | message: "This connection cannot update the thread's branch.", | |
| 87 | }), | |
| 88 | ), | |
| 89 | ); | |
| 90 | } | |
| 66 | 91 | return updateThreadMetadata({ |
| 67 | 92 | environmentId: thread.environmentId, |
| 68 | 93 | input: { |
| ... | ... | @@ -131,9 +156,16 @@ export function useSelectedThreadGitActions() { |
| 131 | 156 | readonly project: EnvironmentProject; |
| 132 | 157 | readonly cwd: string; |
| 133 | 158 | }) => Promise<AtomCommandResult<T, E>>, |
| 134 | options?: { readonly managedExternally?: boolean }, | |
| 159 | options?: { readonly managedExternally?: boolean; readonly changesThreadBranch?: boolean }, | |
| 135 | 160 | ): Promise<T | null> => { |
| 136 | if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { | |
| 161 | if ( | |
| 162 | !selectedThread || | |
| 163 | !selectedThreadProject || | |
| 164 | !selectedThreadCwd || | |
| 165 | !readEnvironmentScope(selectedThread.environmentId, AuthSourceControlWriteScope) || | |
| 166 | (options?.changesThreadBranch === true && | |
| 167 | !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope)) | |
| 168 | ) { | |
| 137 | 169 | return null; |
| 138 | 170 | } |
| 139 | 171 | |
| ... | ... | @@ -180,22 +212,24 @@ export function useSelectedThreadGitActions() { |
| 180 | 212 | readonly worktreePath?: string | null; |
| 181 | 213 | }; |
| 182 | 214 | }): Promise<AtomCommandResult<void, unknown>> => { |
| 183 | if (input.nextThreadState) { | |
| 184 | const updateResult = await updateThreadGitContext(input.thread, input.nextThreadState); | |
| 185 | if (AsyncResult.isFailure(updateResult)) { | |
| 186 | return AsyncResult.failure(updateResult.cause); | |
| 187 | } | |
| 188 | } | |
| 215 | // The Git mutation already landed; refresh what the worktree shows even | |
| 216 | // when the thread metadata update is denied, so the sheet does not keep | |
| 217 | // displaying the previous branch. | |
| 218 | const updateResult = input.nextThreadState | |
| 219 | ? await updateThreadGitContext(input.thread, input.nextThreadState) | |
| 220 | : AsyncResult.success(undefined); | |
| 189 | 221 | branchState.refresh(); |
| 190 | 222 | await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); |
| 191 | return AsyncResult.success(undefined); | |
| 223 | return AsyncResult.isFailure(updateResult) | |
| 224 | ? AsyncResult.failure(updateResult.cause) | |
| 225 | : AsyncResult.success(undefined); | |
| 192 | 226 | }, |
| 193 | 227 | [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext], |
| 194 | 228 | ); |
| 195 | 229 | |
| 196 | 230 | const onCheckoutSelectedThreadBranch = useCallback( |
| 197 | 231 | async (branch: string) => { |
| 198 | await runSelectedThreadGitMutation( | |
| 232 | return runSelectedThreadGitMutation( | |
| 199 | 233 | "switch_ref", |
| 200 | 234 | "Switching branch", |
| 201 | 235 | async ({ thread, cwd }) => { |
| ... | ... | @@ -216,6 +250,7 @@ export function useSelectedThreadGitActions() { |
| 216 | 250 | }); |
| 217 | 251 | return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; |
| 218 | 252 | }, |
| 253 | { changesThreadBranch: true }, | |
| 219 | 254 | ); |
| 220 | 255 | }, |
| 221 | 256 | [ |
| ... | ... | @@ -228,7 +263,7 @@ export function useSelectedThreadGitActions() { |
| 228 | 263 | |
| 229 | 264 | const onCreateSelectedThreadBranch = useCallback( |
| 230 | 265 | async (branch: string) => { |
| 231 | await runSelectedThreadGitMutation( | |
| 266 | return runSelectedThreadGitMutation( | |
| 232 | 267 | "create_ref", |
| 233 | 268 | "Creating branch", |
| 234 | 269 | async ({ thread, cwd }) => { |
| ... | ... | @@ -249,6 +284,7 @@ export function useSelectedThreadGitActions() { |
| 249 | 284 | }); |
| 250 | 285 | return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; |
| 251 | 286 | }, |
| 287 | { changesThreadBranch: true }, | |
| 252 | 288 | ); |
| 253 | 289 | }, |
| 254 | 290 | [ |
| ... | ... | @@ -261,7 +297,7 @@ export function useSelectedThreadGitActions() { |
| 261 | 297 | |
| 262 | 298 | const onCreateSelectedThreadWorktree = useCallback( |
| 263 | 299 | async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { |
| 264 | await runSelectedThreadGitMutation( | |
| 300 | return runSelectedThreadGitMutation( | |
| 265 | 301 | "create_worktree", |
| 266 | 302 | "Creating worktree", |
| 267 | 303 | async ({ thread, project }) => { |
| ... | ... | @@ -287,6 +323,7 @@ export function useSelectedThreadGitActions() { |
| 287 | 323 | }); |
| 288 | 324 | return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; |
| 289 | 325 | }, |
| 326 | { changesThreadBranch: true }, | |
| 290 | 327 | ); |
| 291 | 328 | }, |
| 292 | 329 | [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], |
| ... | ... | @@ -337,14 +374,6 @@ export function useSelectedThreadGitActions() { |
| 337 | 374 | return result; |
| 338 | 375 | } |
| 339 | 376 | |
| 340 | showGitActionResult({ | |
| 341 | type: "success", | |
| 342 | title: result.value.toast.title, | |
| 343 | description: result.value.toast.description, | |
| 344 | prUrl: | |
| 345 | result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, | |
| 346 | }); | |
| 347 | ||
| 348 | 377 | if (result.value.branch.status === "created" && result.value.branch.name) { |
| 349 | 378 | const syncResult = await syncSelectedThreadBranchState({ |
| 350 | 379 | thread, |
| ... | ... | @@ -360,9 +389,16 @@ export function useSelectedThreadGitActions() { |
| 360 | 389 | } else { |
| 361 | 390 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); |
| 362 | 391 | } |
| 392 | showGitActionResult({ | |
| 393 | type: "success", | |
| 394 | title: result.value.toast.title, | |
| 395 | description: result.value.toast.description, | |
| 396 | prUrl: | |
| 397 | result.value.toast.cta.kind === "open_pr" ? result.value.toast.cta.url : undefined, | |
| 398 | }); | |
| 363 | 399 | return result; |
| 364 | 400 | }, |
| 365 | { managedExternally: true }, | |
| 401 | { managedExternally: true, changesThreadBranch: input.featureBranch === true }, | |
| 366 | 402 | ); |
| 367 | 403 | }, |
| 368 | 404 | [ |
| ... | ... | @@ -375,6 +411,8 @@ export function useSelectedThreadGitActions() { |
| 375 | 411 | ); |
| 376 | 412 | |
| 377 | 413 | return { |
| 414 | canWriteSourceControl, | |
| 415 | canChangeThreadBranch, | |
| 378 | 416 | refreshSelectedThreadGitStatus, |
| 379 | 417 | refreshSelectedThreadBranches, |
| 380 | 418 | onCheckoutSelectedThreadBranch, |
apps/server/src/auth/RpcAuthorization.test.ts+61| ... | ... | @@ -4,8 +4,10 @@ import { |
| 4 | 4 | AuthSettingsWriteScope, |
| 5 | 5 | DEFAULT_SERVER_SETTINGS, |
| 6 | 6 | ProviderInstanceId, |
| 7 | ThreadId, | |
| 7 | 8 | AuthOrchestrationOperateScope, |
| 8 | 9 | AuthOrchestrationReadScope, |
| 10 | AuthSourceControlWriteScope, | |
| 9 | 11 | AuthRelayReadScope, |
| 10 | 12 | AuthRelayWriteScope, |
| 11 | 13 | WS_METHODS, |
| ... | ... | @@ -113,6 +115,28 @@ describe("RPC authorization scopes", () => { |
| 113 | 115 | ); |
| 114 | 116 | }); |
| 115 | 117 | |
| 118 | it("requires source control writes to start, retry, or cancel project clones", () => { | |
| 119 | for (const method of [ | |
| 120 | WS_METHODS.projectCloneStart, | |
| 121 | WS_METHODS.projectCloneRetry, | |
| 122 | WS_METHODS.projectCloneCancel, | |
| 123 | ]) { | |
| 124 | expect(requiredScopeForRpcMethod(method)).toBe(AuthSourceControlWriteScope); | |
| 125 | } | |
| 126 | expect(requiredScopeForRpcMethod(WS_METHODS.subscribeProjectClones)).toBe( | |
| 127 | AuthOrchestrationReadScope, | |
| 128 | ); | |
| 129 | }); | |
| 130 | ||
| 131 | it("separates viewing pull request file progress from writing it", () => { | |
| 132 | expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsFilesViewed)).toBe( | |
| 133 | AuthOrchestrationReadScope, | |
| 134 | ); | |
| 135 | expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsSetFilesViewed)).toBe( | |
| 136 | AuthSourceControlWriteScope, | |
| 137 | ); | |
| 138 | }); | |
| 139 | ||
| 116 | 140 | it("rejects unknown RPC method names", () => { |
| 117 | 141 | for (const method of ["server.notRegistered", "toString", "constructor"]) { |
| 118 | 142 | expect(() => requiredScopeForRpcMethod(method)).toThrow( |
| ... | ... | @@ -240,3 +264,40 @@ describe("settings mutation authorization", () => { |
| 240 | 264 | }).pipe(Effect.scoped), |
| 241 | 265 | ); |
| 242 | 266 | }); |
| 267 | ||
| 268 | it.effect("requires task permission before attaching a prepared worktree to a thread", () => | |
| 269 | Effect.gen(function* () { | |
| 270 | const group = WsRpcGroup.omit( | |
| 271 | ...[...WsRpcGroup.requests.keys()].filter( | |
| 272 | ( | |
| 273 | tag, | |
| 274 | ): tag is Exclude< | |
| 275 | keyof typeof RPC_REQUIRED_SCOPES, | |
| 276 | typeof WS_METHODS.gitPreparePullRequestThread | |
| 277 | > => tag !== WS_METHODS.gitPreparePullRequestThread, | |
| 278 | ), | |
| 279 | ); | |
| 280 | let handled = false; | |
| 281 | const client = yield* RpcTest.makeClient(group).pipe( | |
| 282 | Effect.provide( | |
| 283 | Layer.mergeAll( | |
| 284 | group.toLayerHandler(WS_METHODS.gitPreparePullRequestThread, () => | |
| 285 | Effect.sync(() => { | |
| 286 | handled = true; | |
| 287 | }).pipe(Effect.andThen(Effect.never)), | |
| 288 | ), | |
| 289 | RpcAuthorization.layer([AuthSourceControlWriteScope]), | |
| 290 | ), | |
| 291 | ), | |
| 292 | ); | |
| 293 | expect( | |
| 294 | yield* client[WS_METHODS.gitPreparePullRequestThread]({ | |
| 295 | cwd: "/repo", | |
| 296 | reference: "42", | |
| 297 | mode: "worktree", | |
| 298 | threadId: ThreadId.make("thread"), | |
| 299 | }).pipe(Effect.flip), | |
| 300 | ).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); | |
| 301 | expect(handled).toBe(false); | |
| 302 | }).pipe(Effect.scoped), | |
| 303 | ); |
apps/server/src/auth/RpcAuthorization.ts+13-29| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { |
| 2 | 2 | CLIENT_GUARDED_RPC_SCOPES, |
| 3 | 3 | type DeviceListInput, |
| 4 | clientRpcRequiredScopes, | |
| 4 | 5 | AuthAccessReadScope, |
| 5 | 6 | ServerSettingsPatch, |
| 6 | 7 | ProviderInstanceMutation, |
| ... | ... | @@ -120,15 +121,6 @@ export const RPC_REQUIRED_SCOPES = { |
| 120 | 121 | [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, |
| 121 | 122 | [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, |
| 122 | 123 | [WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope, |
| 123 | [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, | |
| 124 | [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope, | |
| 125 | [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, | |
| 126 | [WS_METHODS.pullRequestsUpdateComment]: AuthOrchestrationOperateScope, | |
| 127 | [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope, | |
| 128 | [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, | |
| 129 | [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, | |
| 130 | [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope, | |
| 131 | [WS_METHODS.pullRequestsSetFilesViewed]: AuthOrchestrationOperateScope, | |
| 132 | 124 | // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only |
| 133 | 125 | // client pressing refresh must not be told it may not look again. |
| 134 | 126 | [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, |
| ... | ... | @@ -136,15 +128,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 136 | 128 | // The candidate list is a read like the detail beside it; asking somebody for a review is a |
| 137 | 129 | // write like every other one. |
| 138 | 130 | [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, |
| 139 | [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, | |
| 140 | 131 | [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, |
| 141 | [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, | |
| 142 | 132 | [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, |
| 143 | [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, | |
| 144 | [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, | |
| 145 | [WS_METHODS.projectCloneStart]: AuthOrchestrationOperateScope, | |
| 146 | [WS_METHODS.projectCloneCancel]: AuthOrchestrationOperateScope, | |
| 147 | [WS_METHODS.projectCloneRetry]: AuthOrchestrationOperateScope, | |
| 148 | 133 | [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, |
| 149 | 134 | [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, |
| 150 | 135 | [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, |
| ... | ... | @@ -167,16 +152,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 167 | 152 | [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, |
| 168 | 153 | [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, |
| 169 | 154 | [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, |
| 170 | [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, | |
| 171 | [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, | |
| 172 | [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, | |
| 173 | [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, | |
| 155 | [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, | |
| 174 | 156 | [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, |
| 175 | [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, | |
| 176 | [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, | |
| 177 | [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, | |
| 178 | [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope, | |
| 179 | [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope, | |
| 180 | 157 | [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, |
| 181 | 158 | [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, |
| 182 | 159 | [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, |
| ... | ... | @@ -246,13 +223,20 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => { |
| 246 | 223 | : [...new Set([...scopes, AuthProvidersManageScope])]; |
| 247 | 224 | }; |
| 248 | 225 | |
| 226 | const requiredScopesForRpcCall = ( | |
| 227 | method: string, | |
| 228 | payload: unknown, | |
| 229 | ): ReadonlyArray<AuthEnvironmentScope> => { | |
| 230 | if (method === WS_METHODS.serverUpdateSettings) return requiredScopesForSettingsUpdate(payload); | |
| 231 | const guarded = clientRpcRequiredScopes(method, payload); | |
| 232 | if (guarded.length > 0) return guarded; | |
| 233 | return [requiredScopeForRpcMethod(method)]; | |
| 234 | }; | |
| 235 | ||
| 249 | 236 | /** Authorizes every RPC on one connection against that connection's session scopes. */ |
| 250 | 237 | export const layer = (scopes: ReadonlyArray<AuthEnvironmentScope>) => |
| 251 | 238 | Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => { |
| 252 | const requiredScopes = | |
| 253 | rpc._tag === WS_METHODS.serverUpdateSettings | |
| 254 | ? requiredScopesForSettingsUpdate(payload) | |
| 255 | : [requiredScopeForRpcMethod(rpc._tag)]; | |
| 239 | const requiredScopes = requiredScopesForRpcCall(rpc._tag, payload); | |
| 256 | 240 | const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope)); |
| 257 | 241 | return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope)); |
| 258 | 242 | }); |
apps/server/src/auth/http.ts+2| ... | ... | @@ -10,6 +10,7 @@ import { |
| 10 | 10 | AuthRelayReadScope, |
| 11 | 11 | AuthRelayWriteScope, |
| 12 | 12 | AuthReviewWriteScope, |
| 13 | AuthSourceControlWriteScope, | |
| 13 | 14 | AuthTerminalOperateScope, |
| 14 | 15 | EnvironmentAuthInvalidError, |
| 15 | 16 | type EnvironmentAuthInvalidReason, |
| ... | ... | @@ -374,6 +375,7 @@ export const layer = HttpApiBuilder.group( |
| 374 | 375 | AuthEnvironmentMaintainScope, |
| 375 | 376 | AuthTerminalOperateScope, |
| 376 | 377 | AuthReviewWriteScope, |
| 378 | AuthSourceControlWriteScope, | |
| 377 | 379 | AuthAccessReadScope, |
| 378 | 380 | AuthAccessWriteScope, |
| 379 | 381 | AuthRelayReadScope, |
apps/web/src/components/BranchPicker.tsx+3| ... | ... | @@ -264,6 +264,7 @@ export function BranchPickerRefItem({ |
| 264 | 264 | projectCwd: activeProjectCwd, |
| 265 | 265 | index, |
| 266 | 266 | value, |
| 267 | disabled, | |
| 267 | 268 | onClick, |
| 268 | 269 | onContextMenu, |
| 269 | 270 | }: { |
| ... | ... | @@ -271,6 +272,7 @@ export function BranchPickerRefItem({ |
| 271 | 272 | projectCwd: string | null; |
| 272 | 273 | index: number; |
| 273 | 274 | value?: string; |
| 275 | disabled?: boolean; | |
| 274 | 276 | onClick: ComponentProps<typeof ComboboxItem>["onClick"]; |
| 275 | 277 | onContextMenu?: ComponentProps<typeof ComboboxItem>["onContextMenu"]; |
| 276 | 278 | }) { |
| ... | ... | @@ -288,6 +290,7 @@ export function BranchPickerRefItem({ |
| 288 | 290 | : null; |
| 289 | 291 | return ( |
| 290 | 292 | <ComboboxItem |
| 293 | disabled={disabled} | |
| 291 | 294 | hideIndicator |
| 292 | 295 | key={itemValue} |
| 293 | 296 | index={index} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+51-5| ... | ... | @@ -8,7 +8,14 @@ import { |
| 8 | 8 | isAtomCommandInterrupted, |
| 9 | 9 | squashAtomCommandFailure, |
| 10 | 10 | } from "@t3tools/client-runtime/state/runtime"; |
| 11 | import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; | |
| 11 | import { | |
| 12 | AuthOrchestrationOperateScope, | |
| 13 | AuthSourceControlWriteScope, | |
| 14 | type ContextMenuItem, | |
| 15 | type EnvironmentId, | |
| 16 | type VcsRef, | |
| 17 | type ThreadId, | |
| 18 | } from "@t3tools/contracts"; | |
| 12 | 19 | import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; |
| 13 | 20 | import { |
| 14 | 21 | useCallback, |
| ... | ... | @@ -30,6 +37,7 @@ import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 30 | 37 | import { usePaginatedBranches } from "../state/queries"; |
| 31 | 38 | import { useProject, useThreadShell } from "../state/entities"; |
| 32 | 39 | import { useEnvironmentQuery } from "../state/query"; |
| 40 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 33 | 41 | import { threadEnvironment } from "../state/threads"; |
| 34 | 42 | import { useAtomCommand } from "../state/use-atom-command"; |
| 35 | 43 | import { vcsEnvironment } from "../state/vcs"; |
| ... | ... | @@ -110,6 +118,8 @@ export function BranchToolbarBranchSelector({ |
| 110 | 118 | onComposerFocusRequest, |
| 111 | 119 | }: BranchToolbarBranchSelectorProps) { |
| 112 | 120 | const composerFloatingLayerProps = useComposerMenuProps(); |
| 121 | const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); | |
| 122 | const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | |
| 113 | 123 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); |
| 114 | 124 | const updateThreadMetadata = useAtomCommand( |
| 115 | 125 | threadEnvironment.updateMetadata, |
| ... | ... | @@ -153,6 +163,8 @@ export function BranchToolbarBranchSelector({ |
| 153 | 163 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; |
| 154 | 164 | const branchCwd = activeWorktreePath ?? activeProjectCwd; |
| 155 | 165 | const hasServerThread = serverThread !== null; |
| 166 | const canUpdateThreadBranch = !hasServerThread || canOperateThread; | |
| 167 | const canChangeThreadBranch = canWriteSourceControl && canUpdateThreadBranch; | |
| 156 | 168 | const effectiveEnvMode = |
| 157 | 169 | effectiveEnvModeOverride ?? |
| 158 | 170 | resolveEffectiveEnvMode({ |
| ... | ... | @@ -166,7 +178,12 @@ export function BranchToolbarBranchSelector({ |
| 166 | 178 | // --------------------------------------------------------------------------- |
| 167 | 179 | const setThreadBranch = useCallback( |
| 168 | 180 | (branch: string | null, worktreePath: string | null, automatic = false) => { |
| 169 | if (!activeThreadId || !activeProject) return; | |
| 181 | if ( | |
| 182 | !activeThreadId || | |
| 183 | !activeProject || | |
| 184 | (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) | |
| 185 | ) | |
| 186 | return; | |
| 170 | 187 | if (serverSession && worktreePath !== activeWorktreePath) { |
| 171 | 188 | void stopThreadSession({ |
| 172 | 189 | environmentId, |
| ... | ... | @@ -273,8 +290,11 @@ export function BranchToolbarBranchSelector({ |
| 273 | 290 | const isSelectingWorktreeBase = |
| 274 | 291 | effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; |
| 275 | 292 | const checkoutPullRequestItemValue = |
| 276 | prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; | |
| 277 | const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; | |
| 293 | canChangeThreadBranch && prReference && onCheckoutPullRequestRequest | |
| 294 | ? `__checkout_pull_request__:${prReference}` | |
| 295 | : null; | |
| 296 | const canCreateBranch = | |
| 297 | canChangeThreadBranch && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; | |
| 278 | 298 | // The ref is created under its sanitized name, so the collision check has to |
| 279 | 299 | // use that name too. Matching on the raw query would offer to create a ref |
| 280 | 300 | // that already exists whenever sanitizing changes the name. |
| ... | ... | @@ -392,6 +412,20 @@ export function BranchToolbarBranchSelector({ |
| 392 | 412 | ); |
| 393 | 413 | |
| 394 | 414 | const runBranchAction = (action: () => Promise<void>) => { |
| 415 | if ( | |
| 416 | !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) || | |
| 417 | (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) | |
| 418 | ) { | |
| 419 | // The menu already closed when the item was chosen; explain the no-op. | |
| 420 | toastManager.add( | |
| 421 | stackedThreadToast({ | |
| 422 | type: "error", | |
| 423 | title: "Action unavailable", | |
| 424 | description: "This connection cannot change the thread's branch.", | |
| 425 | }), | |
| 426 | ); | |
| 427 | return; | |
| 428 | } | |
| 395 | 429 | startBranchActionTransition(async () => { |
| 396 | 430 | await action(); |
| 397 | 431 | branchRefState.refresh(); |
| ... | ... | @@ -400,7 +434,7 @@ export function BranchToolbarBranchSelector({ |
| 400 | 434 | }; |
| 401 | 435 | |
| 402 | 436 | const selectBranch = (refName: VcsRef) => { |
| 403 | if (!branchCwd || !activeProjectCwd || isBranchActionPending) return; | |
| 437 | if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; | |
| 404 | 438 | |
| 405 | 439 | if (isSelectingWorktreeBase) { |
| 406 | 440 | setThreadBranch(refName.name, null); |
| ... | ... | @@ -461,6 +495,7 @@ export function BranchToolbarBranchSelector({ |
| 461 | 495 | }; |
| 462 | 496 | |
| 463 | 497 | const createRef = (rawName: string) => { |
| 498 | if (!canChangeThreadBranch) return; | |
| 464 | 499 | const name = sanitizeNewRefName(rawName); |
| 465 | 500 | if (!branchCwd || !name || isBranchActionPending) return; |
| 466 | 501 | |
| ... | ... | @@ -647,6 +682,17 @@ export function BranchToolbarBranchSelector({ |
| 647 | 682 | projectCwd={activeProjectCwd} |
| 648 | 683 | index={index} |
| 649 | 684 | value={itemValue} |
| 685 | disabled={ | |
| 686 | !canUpdateThreadBranch || | |
| 687 | (!canWriteSourceControl && | |
| 688 | !isSelectingWorktreeBase && | |
| 689 | (!activeProjectCwd || | |
| 690 | !resolveBranchSelectionTarget({ | |
| 691 | activeProjectCwd, | |
| 692 | activeWorktreePath, | |
| 693 | refName, | |
| 694 | }).reuseExistingWorktree)) | |
| 695 | } | |
| 650 | 696 | onClick={() => selectPickerItem(itemValue)} |
| 651 | 697 | onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} |
| 652 | 698 | /> |
apps/web/src/components/ChatView.tsx+20-8| ... | ... | @@ -50,6 +50,7 @@ import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue"; |
| 50 | 50 | import { |
| 51 | 51 | AuthOrchestrationOperateScope, |
| 52 | 52 | AuthSettingsWriteScope, |
| 53 | AuthSourceControlWriteScope, | |
| 53 | 54 | type AssistantCitation, |
| 54 | 55 | type ChatFileAttachment, |
| 55 | 56 | CommandId, |
| ... | ... | @@ -397,6 +398,7 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect |
| 397 | 398 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 398 | 399 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 399 | 400 | import { useEnvironmentQuery } from "../state/query"; |
| 401 | import { useEnvironmentScope } from "~/state/session"; | |
| 400 | 402 | import { |
| 401 | 403 | environmentServerConfigsAtom, |
| 402 | 404 | primaryServerAvailableEditorsAtom, |
| ... | ... | @@ -559,11 +561,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at |
| 559 | 561 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 560 | 562 | import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; |
| 561 | 563 | import { assetEnvironment } from "../state/assets"; |
| 562 | import { | |
| 563 | readEnvironmentScope, | |
| 564 | readPreparedConnection, | |
| 565 | useEnvironmentScope, | |
| 566 | } from "../state/session"; | |
| 564 | import { readEnvironmentScope, readPreparedConnection } from "../state/session"; | |
| 567 | 565 | import { useAtomCommand } from "../state/use-atom-command"; |
| 568 | 566 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; |
| 569 | 567 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| ... | ... | @@ -1577,6 +1575,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1577 | 1575 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { |
| 1578 | 1576 | reportFailure: false, |
| 1579 | 1577 | }); |
| 1578 | const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); | |
| 1580 | 1579 | const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); |
| 1581 | 1580 | const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { |
| 1582 | 1581 | reportFailure: false, |
| ... | ... | @@ -2164,7 +2163,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2164 | 2163 | (isServerThread ? activeThread?.runtimeMode : undefined) ?? |
| 2165 | 2164 | defaultRuntimeMode; |
| 2166 | 2165 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 2167 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; | |
| 2166 | const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread; | |
| 2168 | 2167 | const activeThreadId = activeThread?.id ?? null; |
| 2169 | 2168 | // Prefer the larger of turn-item-committed ids and projection messages so |
| 2170 | 2169 | // env lock does not unlock while turn items lag projection hydration. |
| ... | ... | @@ -7161,6 +7160,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7161 | 7160 | }); |
| 7162 | 7161 | }, [activeBranchMismatchKey, showBranchMismatchBanner]); |
| 7163 | 7162 | const handleSwitchCheckoutToThread = useCallback(async () => { |
| 7163 | if (!canWriteSourceControl) return; | |
| 7164 | 7164 | if ( |
| 7165 | 7165 | !activeProjectCwd || |
| 7166 | 7166 | !activeThread || |
| ... | ... | @@ -7191,6 +7191,10 @@ export default function ChatView(props: ChatViewProps) { |
| 7191 | 7191 | return; |
| 7192 | 7192 | } |
| 7193 | 7193 | |
| 7194 | if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) { | |
| 7195 | setIsRestoringThreadBranch(false); | |
| 7196 | return; | |
| 7197 | } | |
| 7194 | 7198 | const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; |
| 7195 | 7199 | if (nextBranch !== activeThread.branch) { |
| 7196 | 7200 | const updateResult = await updateThreadMetadata({ |
| ... | ... | @@ -7216,6 +7220,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7216 | 7220 | setIsRestoringThreadBranch(false); |
| 7217 | 7221 | scheduleComposerFocus(); |
| 7218 | 7222 | }, [ |
| 7223 | canWriteSourceControl, | |
| 7219 | 7224 | activeProjectCwd, |
| 7220 | 7225 | activeThread, |
| 7221 | 7226 | environmentId, |
| ... | ... | @@ -7622,12 +7627,17 @@ export default function ChatView(props: ChatViewProps) { |
| 7622 | 7627 | selectedProvider, |
| 7623 | 7628 | ]); |
| 7624 | 7629 | const handleRestoreThreadBranch = useCallback(() => { |
| 7630 | if (!canWriteSourceControl) return; | |
| 7625 | 7631 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { |
| 7626 | 7632 | setBranchRestoreConfirmOpen(true); |
| 7627 | 7633 | return; |
| 7628 | 7634 | } |
| 7629 | 7635 | void handleSwitchCheckoutToThread(); |
| 7630 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); | |
| 7636 | }, [ | |
| 7637 | canWriteSourceControl, | |
| 7638 | gitStatusQuery.data?.hasWorkingTreeChanges, | |
| 7639 | handleSwitchCheckoutToThread, | |
| 7640 | ]); | |
| 7631 | 7641 | const feedbackBannerItems = useMemo( |
| 7632 | 7642 | () => |
| 7633 | 7643 | feedbackSubmissions.flatMap((submission) => { |
| ... | ... | @@ -7722,7 +7732,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7722 | 7732 | <Button |
| 7723 | 7733 | size="xs" |
| 7724 | 7734 | variant="ghost" |
| 7725 | disabled={isRestoringThreadBranch} | |
| 7735 | disabled={!canWriteSourceControl || isRestoringThreadBranch} | |
| 7726 | 7736 | onClick={handleRestoreThreadBranch} |
| 7727 | 7737 | > |
| 7728 | 7738 | {isRestoringThreadBranch ? "Restoring..." : "Restore branch"} |
| ... | ... | @@ -7740,6 +7750,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7740 | 7750 | activeBranchMismatchKey, |
| 7741 | 7751 | activeThreadShell, |
| 7742 | 7752 | serverRuntime?.usageLimitResetAt, |
| 7753 | canWriteSourceControl, | |
| 7743 | 7754 | feedbackBannerItems, |
| 7744 | 7755 | limitRecoveryBanner, |
| 7745 | 7756 | handleRestoreThreadBranch, |
| ... | ... | @@ -11654,6 +11665,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11654 | 11665 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 11655 | 11666 | <Button |
| 11656 | 11667 | variant="default" |
| 11668 | disabled={!canWriteSourceControl} | |
| 11657 | 11669 | onClick={() => { |
| 11658 | 11670 | setBranchRestoreConfirmOpen(false); |
| 11659 | 11671 | void handleSwitchCheckoutToThread(); |
apps/web/src/components/CommandPalette.tsx+45-4| ... | ... | @@ -30,6 +30,8 @@ import { |
| 30 | 30 | squashAtomCommandFailure, |
| 31 | 31 | } from "@t3tools/client-runtime/state/runtime"; |
| 32 | 32 | import { |
| 33 | AuthOrchestrationOperateScope, | |
| 34 | AuthSourceControlWriteScope, | |
| 33 | 35 | type DesktopWslState, |
| 34 | 36 | type EnvironmentId, |
| 35 | 37 | type EnvironmentMachineKind, |
| ... | ... | @@ -103,6 +105,7 @@ import { projectEnvironment } from "../state/projects"; |
| 103 | 105 | import { useEnvironmentQuery } from "../state/query"; |
| 104 | 106 | import { serverEnvironment } from "../state/server"; |
| 105 | 107 | import { threadEnvironment } from "../state/threads"; |
| 108 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 106 | 109 | import { sourceControlEnvironment } from "../state/sourceControl"; |
| 107 | 110 | import { useAtomCommand } from "../state/use-atom-command"; |
| 108 | 111 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| ... | ... | @@ -1039,6 +1042,7 @@ function OpenCommandPaletteDialog(props: { |
| 1039 | 1042 | [addProjectEnvironmentOptions, environments], |
| 1040 | 1043 | ); |
| 1041 | 1044 | const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; |
| 1045 | const canCreateProject = useEnvironmentScope(browseEnvironmentId, AuthOrchestrationOperateScope); | |
| 1042 | 1046 | const browseEnvironment = |
| 1043 | 1047 | environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null; |
| 1044 | 1048 | // A desktop-local secondary backend (today: the WSL backend). The picker is |
| ... | ... | @@ -2418,6 +2422,16 @@ function OpenCommandPaletteDialog(props: { |
| 2418 | 2422 | return; |
| 2419 | 2423 | } |
| 2420 | 2424 | |
| 2425 | if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) { | |
| 2426 | toastManager.add( | |
| 2427 | stackedThreadToast({ | |
| 2428 | type: "error", | |
| 2429 | title: "Cannot add project", | |
| 2430 | description: "This connection cannot add projects.", | |
| 2431 | }), | |
| 2432 | ); | |
| 2433 | return; | |
| 2434 | } | |
| 2421 | 2435 | const projectId = newProjectId(); |
| 2422 | 2436 | const createResult = await createProject({ |
| 2423 | 2437 | environmentId: input.environmentId, |
| ... | ... | @@ -2607,9 +2621,20 @@ function OpenCommandPaletteDialog(props: { |
| 2607 | 2621 | } |
| 2608 | 2622 | |
| 2609 | 2623 | const rawDestination = (destinationPathInput ?? query).trim(); |
| 2610 | if (rawDestination.length === 0 || isRemoteProjectCloning) { | |
| 2624 | if ( | |
| 2625 | !readEnvironmentScope(addProjectCloneFlow.environmentId, AuthSourceControlWriteScope) || | |
| 2626 | !readEnvironmentScope(addProjectCloneFlow.environmentId, AuthOrchestrationOperateScope) | |
| 2627 | ) { | |
| 2628 | toastManager.add( | |
| 2629 | stackedThreadToast({ | |
| 2630 | type: "error", | |
| 2631 | title: "Clone unavailable", | |
| 2632 | description: "This connection needs permission to write source control and add projects.", | |
| 2633 | }), | |
| 2634 | ); | |
| 2611 | 2635 | return; |
| 2612 | 2636 | } |
| 2637 | if (rawDestination.length === 0 || isRemoteProjectCloning) return; | |
| 2613 | 2638 | |
| 2614 | 2639 | if (isUnsupportedWindowsProjectPath(rawDestination, browseEnvironmentPlatform)) { |
| 2615 | 2640 | toastManager.add( |
| ... | ... | @@ -2948,9 +2973,21 @@ function OpenCommandPaletteDialog(props: { |
| 2948 | 2973 | getCommandPaletteInputPlaceholder(paletteMode)); |
| 2949 | 2974 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; |
| 2950 | 2975 | const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; |
| 2976 | const canWriteSourceControl = useEnvironmentScope( | |
| 2977 | addProjectCloneFlow?.environmentId ?? null, | |
| 2978 | AuthSourceControlWriteScope, | |
| 2979 | ); | |
| 2980 | const canCreateClonedProject = useEnvironmentScope( | |
| 2981 | addProjectCloneFlow?.environmentId ?? null, | |
| 2982 | AuthOrchestrationOperateScope, | |
| 2983 | ); | |
| 2984 | const canCloneProject = canWriteSourceControl && canCreateClonedProject; | |
| 2985 | const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; | |
| 2951 | 2986 | const canSubmitBrowsePath = |
| 2952 | 2987 | isBrowsing && |
| 2953 | 2988 | !relativePathNeedsActiveProject && |
| 2989 | canCreateProject && | |
| 2990 | (!isCloneDestinationStep || canCloneProject) && | |
| 2954 | 2991 | canCreateProjectInEnvironment(browseEnvironment?.connection.phase); |
| 2955 | 2992 | const willCreateProjectPath = |
| 2956 | 2993 | canSubmitBrowsePath && |
| ... | ... | @@ -2960,7 +2997,6 @@ function OpenCommandPaletteDialog(props: { |
| 2960 | 2997 | (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); |
| 2961 | 2998 | const useMetaForMod = isMacPlatform(navigator.platform); |
| 2962 | 2999 | const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl"; |
| 2963 | const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; | |
| 2964 | 3000 | const submitActionLabel = isCloneDestinationStep |
| 2965 | 3001 | ? willCreateProjectPath |
| 2966 | 3002 | ? "Create & Clone" |
| ... | ... | @@ -3327,9 +3363,10 @@ function OpenCommandPaletteDialog(props: { |
| 3327 | 3363 | className="absolute inset-e-2.5 top-1/2 -translate-y-1/2" |
| 3328 | 3364 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} |
| 3329 | 3365 | disabled={ |
| 3366 | !canCreateProject || | |
| 3330 | 3367 | !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || |
| 3331 | 3368 | relativePathNeedsActiveProject || |
| 3332 | (isCloneDestinationStep && isRemoteProjectPending) | |
| 3369 | (isCloneDestinationStep && (!canCloneProject || isRemoteProjectPending)) | |
| 3333 | 3370 | } |
| 3334 | 3371 | onMouseDown={(event) => { |
| 3335 | 3372 | event.preventDefault(); |
| ... | ... | @@ -3355,7 +3392,11 @@ function OpenCommandPaletteDialog(props: { |
| 3355 | 3392 | </KbdGroup> |
| 3356 | 3393 | </TooltipTrigger> |
| 3357 | 3394 | <TooltipPopup side="top"> |
| 3358 | {submitActionLabel} ({addShortcutLabel}) | |
| 3395 | {isCloneDestinationStep && !canCloneProject | |
| 3396 | ? "This connection needs permission to write source control and add projects." | |
| 3397 | : canCreateProject | |
| 3398 | ? `${submitActionLabel} (${addShortcutLabel})` | |
| 3399 | : "This connection cannot add projects."} | |
| 3359 | 3400 | </TooltipPopup> |
| 3360 | 3401 | </Tooltip> |
| 3361 | 3402 | ) : null; |
apps/web/src/components/GitActionsControl.test.ts created+295| ... | ... | @@ -0,0 +1,295 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | AuthSourceControlWriteScope, | |
| 4 | EnvironmentId, | |
| 5 | ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Cause from "effect/Cause"; | |
| 8 | import { AsyncResult } from "effect/reactivity"; | |
| 9 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 10 | ||
| 11 | const state = vi.hoisted(() => ({ | |
| 12 | scopes: new Set<string>(), | |
| 13 | primaryScopes: new Set<string>(), | |
| 14 | shell: { branch: "main" } as { branch: string } | null, | |
| 15 | detail: null as { branch: string } | null, | |
| 16 | draft: null as { branch: string; worktreePath: null; envMode: "local" } | null, | |
| 17 | branch: "main", | |
| 18 | commits: 0, | |
| 19 | metadataRequests: [] as { environmentId: string; branch: string }[], | |
| 20 | afterGitAction: undefined as (() => void) | undefined, | |
| 21 | run: null as ((input: { action: "commit"; featureBranch?: boolean }) => Promise<void>) | null, | |
| 22 | })); | |
| 23 | ||
| 24 | vi.mock("react", async (importOriginal) => ({ | |
| 25 | ...(await importOriginal<typeof import("react")>()), | |
| 26 | useCallback: (callback: unknown) => callback, | |
| 27 | useMemo: (factory: () => unknown) => factory(), | |
| 28 | useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}], | |
| 29 | useRef: (current: unknown) => ({ current }), | |
| 30 | useEffect: () => {}, | |
| 31 | useEffectEvent: (callback: typeof state.run) => { | |
| 32 | state.run = callback; | |
| 33 | return callback; | |
| 34 | }, | |
| 35 | })); | |
| 36 | vi.mock("@effect/atom-react", () => ({ | |
| 37 | useAtomValue: (atom: unknown) => (atom === "vcs-state" ? { isRunning: false } : null), | |
| 38 | })); | |
| 39 | vi.mock("~/state/entities", () => ({ | |
| 40 | useThreadProjection: (ref: unknown) => | |
| 41 | ref === null || state.detail === null ? null : { projection: { thread: state.detail } }, | |
| 42 | useThreadShell: () => state.shell, | |
| 43 | })); | |
| 44 | vi.mock("~/state/session", () => ({ | |
| 45 | useEnvironmentScope: (environmentId: unknown, scope: string) => | |
| 46 | (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope), | |
| 47 | readEnvironmentScope: (environmentId: unknown, scope: string) => | |
| 48 | (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope), | |
| 49 | })); | |
| 50 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); | |
| 51 | vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => null } })); | |
| 52 | vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: {} })); | |
| 53 | vi.mock("~/state/vcs", () => ({ | |
| 54 | vcsEnvironment: { status: () => null }, | |
| 55 | vcsActionManager: { stateAtom: () => "vcs-state" }, | |
| 56 | })); | |
| 57 | vi.mock("~/state/threads", () => ({ | |
| 58 | threadEnvironment: { | |
| 59 | updateMetadata: async ({ | |
| 60 | environmentId, | |
| 61 | input, | |
| 62 | }: { | |
| 63 | environmentId: string; | |
| 64 | input: { branch: string }; | |
| 65 | }) => { | |
| 66 | state.metadataRequests.push({ environmentId, branch: input.branch }); | |
| 67 | if (!state.scopes.has(AuthOrchestrationOperateScope)) | |
| 68 | return AsyncResult.failure(Cause.fail(new Error("Task denied"))); | |
| 69 | if (state.shell) state.shell.branch = input.branch; | |
| 70 | if (state.detail) state.detail.branch = input.branch; | |
| 71 | return AsyncResult.success(undefined); | |
| 72 | }, | |
| 73 | }, | |
| 74 | })); | |
| 75 | vi.mock("~/state/query", () => ({ | |
| 76 | useEnvironmentQuery: () => ({ | |
| 77 | data: { | |
| 78 | isRepo: true, | |
| 79 | refName: "main", | |
| 80 | isDefaultRef: false, | |
| 81 | hasPrimaryRemote: true, | |
| 82 | hasWorkingTreeChanges: true, | |
| 83 | workingTree: { files: [{ path: "file.ts", status: "modified" }] }, | |
| 84 | }, | |
| 85 | error: null, | |
| 86 | }), | |
| 87 | })); | |
| 88 | vi.mock("~/composerDraftStore", () => ({ | |
| 89 | useComposerDraftStore: ( | |
| 90 | select: (store: { | |
| 91 | getDraftSession: () => typeof state.draft; | |
| 92 | getDraftThreadByRef: () => typeof state.draft; | |
| 93 | setDraftThreadContext: (_target: unknown, input: { branch: string }) => void; | |
| 94 | }) => unknown, | |
| 95 | ) => | |
| 96 | select({ | |
| 97 | getDraftSession: () => state.draft, | |
| 98 | getDraftThreadByRef: () => state.draft, | |
| 99 | setDraftThreadContext: (_target, input) => { | |
| 100 | if (state.draft) state.draft.branch = input.branch; | |
| 101 | }, | |
| 102 | }), | |
| 103 | })); | |
| 104 | vi.mock("~/lib/sourceControlActions", () => ({ | |
| 105 | useSourceControlActionRunning: () => false, | |
| 106 | useVcsInitAction: () => ({}), | |
| 107 | useVcsPullAction: () => ({}), | |
| 108 | useSourceControlPublishRepositoryAction: () => ({}), | |
| 109 | useGitStackedAction: () => ({ | |
| 110 | run: async ({ featureBranch }: { featureBranch?: boolean }) => { | |
| 111 | state.commits += 1; | |
| 112 | if (featureBranch) state.branch = "feature"; | |
| 113 | state.afterGitAction?.(); | |
| 114 | return { | |
| 115 | _tag: "Success", | |
| 116 | value: { | |
| 117 | branch: featureBranch | |
| 118 | ? { status: "created", name: "feature" } | |
| 119 | : { status: "skipped_not_requested" }, | |
| 120 | toast: { title: "Committed", description: "Committed", cta: { kind: "none" } }, | |
| 121 | }, | |
| 122 | }; | |
| 123 | }, | |
| 124 | }), | |
| 125 | })); | |
| 126 | vi.mock("~/lib/utils", () => ({ cn: () => "", randomUUID: () => "action" })); | |
| 127 | vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} })); | |
| 128 | vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => () => {} })); | |
| 129 | vi.mock("~/lib/openPullRequestLink", () => ({ useOpenPrLink: () => () => {} })); | |
| 130 | vi.mock("~/components/ui/toast", () => ({ | |
| 131 | stackedThreadToast: (input: unknown) => input, | |
| 132 | toastManager: { add: () => "toast", update: () => {}, close: () => {} }, | |
| 133 | })); | |
| 134 | vi.mock("~/components/ui/dialog", () => ({ | |
| 135 | Dialog: "Dialog", | |
| 136 | DialogDescription: "DialogDescription", | |
| 137 | DialogFooter: "DialogFooter", | |
| 138 | DialogHeader: "DialogHeader", | |
| 139 | DialogPanel: "DialogPanel", | |
| 140 | DialogPopup: "DialogPopup", | |
| 141 | DialogTitle: "DialogTitle", | |
| 142 | })); | |
| 143 | vi.mock("~/components/ui/group", () => ({ Group: "Group", GroupSeparator: "GroupSeparator" })); | |
| 144 | vi.mock("~/components/ui/menu", () => ({ | |
| 145 | Menu: "Menu", | |
| 146 | MenuItem: "MenuItem", | |
| 147 | MenuItemLabel: "MenuItemLabel", | |
| 148 | MenuPopup: "MenuPopup", | |
| 149 | MenuTrigger: "MenuTrigger", | |
| 150 | })); | |
| 151 | vi.mock("~/components/ui/popover", () => ({ | |
| 152 | Popover: "Popover", | |
| 153 | PopoverPopup: "PopoverPopup", | |
| 154 | PopoverTrigger: "PopoverTrigger", | |
| 155 | })); | |
| 156 | vi.mock("~/components/ui/tooltip", () => ({ | |
| 157 | Tooltip: "Tooltip", | |
| 158 | TooltipPopup: "TooltipPopup", | |
| 159 | TooltipTrigger: "TooltipTrigger", | |
| 160 | })); | |
| 161 | vi.mock("~/components/ui/button", () => ({ Button: "Button" })); | |
| 162 | vi.mock("~/components/ui/checkbox", () => ({ Checkbox: "Checkbox" })); | |
| 163 | vi.mock("~/components/ui/input", () => ({ Input: "Input" })); | |
| 164 | vi.mock("~/components/ui/radio-group", () => ({ RadioGroup: "RadioGroup" })); | |
| 165 | vi.mock("~/components/ui/scroll-area", () => ({ ScrollArea: "ScrollArea" })); | |
| 166 | vi.mock("~/components/ui/spinner", () => ({ Spinner: "Spinner" })); | |
| 167 | vi.mock("~/components/ui/textarea", () => ({ Textarea: "Textarea" })); | |
| 168 | vi.mock("~/components/ui/toggle", () => ({ toggleVariants: () => "" })); | |
| 169 | vi.mock("./AnimatedHeight", () => ({ AnimatedHeight: "AnimatedHeight" })); | |
| 170 | ||
| 171 | import GitActionsControl from "./GitActionsControl"; | |
| 172 | ||
| 173 | function renderActions() { | |
| 174 | GitActionsControl({ | |
| 175 | gitCwd: "/repo", | |
| 176 | activeThreadRef: { | |
| 177 | environmentId: EnvironmentId.make("environment"), | |
| 178 | threadId: ThreadId.make("thread"), | |
| 179 | }, | |
| 180 | }); | |
| 181 | if (!state.run) throw new Error("Git action missing"); | |
| 182 | return state.run; | |
| 183 | } | |
| 184 | ||
| 185 | describe("Git actions while thread details load", () => { | |
| 186 | beforeEach(() => { | |
| 187 | state.scopes = new Set([AuthSourceControlWriteScope]); | |
| 188 | state.primaryScopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]); | |
| 189 | state.shell = { branch: "main" }; | |
| 190 | state.detail = null; | |
| 191 | state.draft = null; | |
| 192 | state.branch = "main"; | |
| 193 | state.commits = 0; | |
| 194 | state.metadataRequests = []; | |
| 195 | state.afterGitAction = undefined; | |
| 196 | state.run = null; | |
| 197 | }); | |
| 198 | ||
| 199 | it("does not create a feature branch for a server thread without task permission", async () => { | |
| 200 | await renderActions()({ action: "commit", featureBranch: true }); | |
| 201 | ||
| 202 | expect(state.branch).toBe("main"); | |
| 203 | expect(state.commits).toBe(0); | |
| 204 | expect(state.shell?.branch).toBe("main"); | |
| 205 | }); | |
| 206 | ||
| 207 | it("commits and synchronizes the server thread before details finish loading", async () => { | |
| 208 | state.primaryScopes.clear(); | |
| 209 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 210 | await renderActions()({ action: "commit", featureBranch: true }); | |
| 211 | ||
| 212 | expect(state.branch).toBe("feature"); | |
| 213 | expect(state.commits).toBe(1); | |
| 214 | expect(state.shell?.branch).toBe("feature"); | |
| 215 | expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); | |
| 216 | }); | |
| 217 | ||
| 218 | it("synchronizes archived thread detail after its shell disappears", async () => { | |
| 219 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 220 | state.detail = { branch: "main" }; | |
| 221 | renderActions(); | |
| 222 | state.shell = null; | |
| 223 | ||
| 224 | await renderActions()({ action: "commit", featureBranch: true }); | |
| 225 | ||
| 226 | expect(state.commits).toBe(1); | |
| 227 | expect(state.branch).toBe("feature"); | |
| 228 | expect(state.detail.branch).toBe("feature"); | |
| 229 | expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); | |
| 230 | }); | |
| 231 | ||
| 232 | it("requires task permission before changing an archived thread's branch", async () => { | |
| 233 | state.shell = null; | |
| 234 | state.detail = { branch: "main" }; | |
| 235 | ||
| 236 | await renderActions()({ action: "commit", featureBranch: true }); | |
| 237 | ||
| 238 | expect(state.commits).toBe(0); | |
| 239 | expect(state.detail.branch).toBe("main"); | |
| 240 | expect(state.metadataRequests).toEqual([]); | |
| 241 | }); | |
| 242 | ||
| 243 | it("uses the current shell branch when cached detail names the new branch", async () => { | |
| 244 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 245 | state.detail = { branch: "feature" }; | |
| 246 | ||
| 247 | await renderActions()({ action: "commit", featureBranch: true }); | |
| 248 | ||
| 249 | expect(state.commits).toBe(1); | |
| 250 | expect(state.shell?.branch).toBe("feature"); | |
| 251 | expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]); | |
| 252 | }); | |
| 253 | ||
| 254 | it("synchronizes a retained callback after the thread grant is gained", async () => { | |
| 255 | const run = renderActions(); | |
| 256 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 257 | await run({ action: "commit", featureBranch: true }); | |
| 258 | expect(state.commits).toBe(1); | |
| 259 | expect(state.shell?.branch).toBe("feature"); | |
| 260 | expect(state.metadataRequests).toHaveLength(1); | |
| 261 | }); | |
| 262 | ||
| 263 | it.each([ | |
| 264 | [AuthOrchestrationOperateScope, false], | |
| 265 | [AuthSourceControlWriteScope, true], | |
| 266 | ] as const)( | |
| 267 | "uses the fresh task grant after Git finishes, revoking %s", | |
| 268 | async (revokedScope, syncsThread) => { | |
| 269 | state.scopes.add(AuthOrchestrationOperateScope); | |
| 270 | state.afterGitAction = () => state.scopes.delete(revokedScope); | |
| 271 | await renderActions()({ action: "commit", featureBranch: true }); | |
| 272 | expect(state.commits).toBe(1); | |
| 273 | expect(state.branch).toBe("feature"); | |
| 274 | expect(state.shell?.branch).toBe(syncsThread ? "feature" : "main"); | |
| 275 | expect(state.metadataRequests).toHaveLength(syncsThread ? 1 : 0); | |
| 276 | }, | |
| 277 | ); | |
| 278 | ||
| 279 | it("keeps ordinary commits available while details load", async () => { | |
| 280 | await renderActions()({ action: "commit" }); | |
| 281 | ||
| 282 | expect(state.commits).toBe(1); | |
| 283 | expect(state.branch).toBe("main"); | |
| 284 | }); | |
| 285 | ||
| 286 | it("keeps feature-branch commits available for a local draft", async () => { | |
| 287 | state.shell = null; | |
| 288 | state.draft = { branch: "main", worktreePath: null, envMode: "local" }; | |
| 289 | await renderActions()({ action: "commit", featureBranch: true }); | |
| 290 | ||
| 291 | expect(state.commits).toBe(1); | |
| 292 | expect(state.draft.branch).toBe("feature"); | |
| 293 | expect(state.metadataRequests).toEqual([]); | |
| 294 | }); | |
| 295 | }); |
apps/web/src/components/GitActionsControl.tsx+61-17| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; |
| 2 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { type ScopedThreadRef } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | AuthOrchestrationOperateScope, | |
| 5 | AuthSourceControlWriteScope, | |
| 6 | type ScopedThreadRef, | |
| 7 | } from "@t3tools/contracts"; | |
| 4 | 8 | import { |
| 5 | 9 | isAtomCommandInterrupted, |
| 6 | 10 | squashAtomCommandFailure, |
| ... | ... | @@ -108,7 +112,8 @@ import { |
| 108 | 112 | useVcsInitAction, |
| 109 | 113 | useVcsPullAction, |
| 110 | 114 | } from "~/lib/sourceControlActions"; |
| 111 | import { useThreadShell } from "~/state/entities"; | |
| 115 | import { useThreadProjection, useThreadShell } from "~/state/entities"; | |
| 116 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 112 | 117 | import { useEnvironmentQuery } from "~/state/query"; |
| 113 | 118 | import { serverEnvironment } from "~/state/server"; |
| 114 | 119 | import { sourceControlEnvironment } from "~/state/sourceControl"; |
| ... | ... | @@ -630,14 +635,19 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 630 | 635 | ] as const; |
| 631 | 636 | |
| 632 | 637 | const canSubmitPublishRepository = useMemo(() => { |
| 633 | if (!selectedPublishProviderReadiness.ready) return false; | |
| 638 | if (!publishRepositoryAction.isAllowed || !selectedPublishProviderReadiness.ready) return false; | |
| 634 | 639 | if (publishRepositoryAction.isPending) return false; |
| 635 | 640 | const repositoryParts = publishRepository.trim().split("/"); |
| 636 | 641 | const owner = repositoryParts[0]?.trim() ?? ""; |
| 637 | 642 | const rest = repositoryParts.slice(1); |
| 638 | 643 | const name = rest.join("/").trim(); |
| 639 | 644 | return owner.length > 0 && name.length > 0; |
| 640 | }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]); | |
| 645 | }, [ | |
| 646 | publishRepository, | |
| 647 | publishRepositoryAction.isAllowed, | |
| 648 | publishRepositoryAction.isPending, | |
| 649 | selectedPublishProviderReadiness, | |
| 650 | ]); | |
| 641 | 651 | |
| 642 | 652 | const submitPublishRepository = useCallback(() => { |
| 643 | 653 | if (!canSubmitPublishRepository) { |
| ... | ... | @@ -1070,6 +1080,11 @@ export default function GitActionsControl({ |
| 1070 | 1080 | ); |
| 1071 | 1081 | const activeEnvironmentId = activeThreadRef?.environmentId ?? null; |
| 1072 | 1082 | const successScopeKey = `${activeEnvironmentId ?? ""}\u0000${gitCwd ?? ""}`; |
| 1083 | const canWriteSourceControl = useEnvironmentScope( | |
| 1084 | activeEnvironmentId, | |
| 1085 | AuthSourceControlWriteScope, | |
| 1086 | ); | |
| 1087 | const canOperateThread = useEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope); | |
| 1073 | 1088 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId)); |
| 1074 | 1089 | const openInPreferredEditor = useOpenInPreferredEditor( |
| 1075 | 1090 | activeEnvironmentId, |
| ... | ... | @@ -1079,7 +1094,7 @@ export default function GitActionsControl({ |
| 1079 | 1094 | () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), |
| 1080 | 1095 | [activeThreadRef], |
| 1081 | 1096 | ); |
| 1082 | const activeServerThread = useThreadShell(activeThreadRef); | |
| 1097 | const activeServerThreadShell = useThreadShell(activeThreadRef); | |
| 1083 | 1098 | const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); |
| 1084 | 1099 | const activeDraftThread = useComposerDraftStore((store) => |
| 1085 | 1100 | draftId |
| ... | ... | @@ -1088,6 +1103,13 @@ export default function GitActionsControl({ |
| 1088 | 1103 | ? store.getDraftThreadByRef(activeThreadRef) |
| 1089 | 1104 | : null, |
| 1090 | 1105 | ); |
| 1106 | const activeServerThreadProjection = useThreadProjection( | |
| 1107 | activeDraftThread !== null && activeServerThreadShell === null ? null : activeThreadRef, | |
| 1108 | ); | |
| 1109 | const activeServerThread = | |
| 1110 | activeServerThreadShell ?? activeServerThreadProjection?.projection.thread ?? null; | |
| 1111 | const isLocalDraftThread = activeDraftThread !== null && activeServerThread === null; | |
| 1112 | const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread); | |
| 1091 | 1113 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 1092 | 1114 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); |
| 1093 | 1115 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| ... | ... | @@ -1120,7 +1142,10 @@ export default function GitActionsControl({ |
| 1120 | 1142 | } |
| 1121 | 1143 | |
| 1122 | 1144 | if (activeServerThread) { |
| 1123 | if (activeServerThread.branch === branch) { | |
| 1145 | if ( | |
| 1146 | !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) || | |
| 1147 | activeServerThread.branch === branch | |
| 1148 | ) { | |
| 1124 | 1149 | return; |
| 1125 | 1150 | } |
| 1126 | 1151 | |
| ... | ... | @@ -1248,9 +1273,11 @@ export default function GitActionsControl({ |
| 1248 | 1273 | resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), |
| 1249 | 1274 | [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], |
| 1250 | 1275 | ); |
| 1251 | const quickActionDisabledReason = quickAction.disabled | |
| 1252 | ? (quickAction.hint ?? "This action is currently unavailable.") | |
| 1253 | : null; | |
| 1276 | const quickActionDisabledReason = !canWriteSourceControl | |
| 1277 | ? "This connection cannot change source control." | |
| 1278 | : quickAction.disabled | |
| 1279 | ? (quickAction.hint ?? "This action is currently unavailable.") | |
| 1280 | : null; | |
| 1254 | 1281 | const gitActionProgress = resolveGitActionProgressPresentation(vcsActionState); |
| 1255 | 1282 | const pendingDefaultBranchActionCopy = pendingDefaultBranchAction |
| 1256 | 1283 | ? resolveDefaultBranchActionDialogCopy({ |
| ... | ... | @@ -1304,6 +1331,14 @@ export default function GitActionsControl({ |
| 1304 | 1331 | featureBranch = false, |
| 1305 | 1332 | filePaths, |
| 1306 | 1333 | }: RunGitActionWithToastInput) => { |
| 1334 | if ( | |
| 1335 | activeEnvironmentId === null || | |
| 1336 | !readEnvironmentScope(activeEnvironmentId, AuthSourceControlWriteScope) || | |
| 1337 | (featureBranch && | |
| 1338 | !isLocalDraftThread && | |
| 1339 | !readEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope)) | |
| 1340 | ) | |
| 1341 | return; | |
| 1307 | 1342 | const actionStatus = statusOverride ?? gitStatusForActions; |
| 1308 | 1343 | const actionBranch = actionStatus?.refName ?? null; |
| 1309 | 1344 | const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; |
| ... | ... | @@ -1459,7 +1494,7 @@ export default function GitActionsControl({ |
| 1459 | 1494 | }; |
| 1460 | 1495 | |
| 1461 | 1496 | const checkoutFeatureBranchAndContinuePendingAction = () => { |
| 1462 | if (!pendingDefaultBranchAction) return; | |
| 1497 | if (!canChangeThreadBranch || !pendingDefaultBranchAction) return; | |
| 1463 | 1498 | const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; |
| 1464 | 1499 | setPendingDefaultBranchAction(null); |
| 1465 | 1500 | void runGitActionWithToast({ |
| ... | ... | @@ -1473,7 +1508,7 @@ export default function GitActionsControl({ |
| 1473 | 1508 | }; |
| 1474 | 1509 | |
| 1475 | 1510 | const runDialogActionOnNewBranch = () => { |
| 1476 | if (!isCommitDialogOpen) return; | |
| 1511 | if (!canChangeThreadBranch || !isCommitDialogOpen) return; | |
| 1477 | 1512 | const commitMessage = dialogCommitMessage.trim(); |
| 1478 | 1513 | |
| 1479 | 1514 | setIsCommitDialogOpen(false); |
| ... | ... | @@ -1616,7 +1651,8 @@ export default function GitActionsControl({ |
| 1616 | 1651 | [gitCwd, openInPreferredEditor, threadToastData], |
| 1617 | 1652 | ); |
| 1618 | 1653 | |
| 1619 | const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; | |
| 1654 | const canPublishRepository = | |
| 1655 | canWriteSourceControl && isRepo && gitStatusForActions !== null && !hasPrimaryRemote; | |
| 1620 | 1656 | |
| 1621 | 1657 | const initializeGit = () => { |
| 1622 | 1658 | void (async () => { |
| ... | ... | @@ -1683,7 +1719,7 @@ export default function GitActionsControl({ |
| 1683 | 1719 | <MenuItem |
| 1684 | 1720 | density={presentation === "menu" ? "touch" : "default"} |
| 1685 | 1721 | key={`${item.id}-${item.label}`} |
| 1686 | disabled={item.disabled} | |
| 1722 | disabled={!canWriteSourceControl || item.disabled} | |
| 1687 | 1723 | onClick={() => { |
| 1688 | 1724 | openDialogForMenuItem(item); |
| 1689 | 1725 | }} |
| ... | ... | @@ -1777,7 +1813,7 @@ export default function GitActionsControl({ |
| 1777 | 1813 | variant={isPanel ? "ghost" : "outline"} |
| 1778 | 1814 | part="row" |
| 1779 | 1815 | panel={isPanel} |
| 1780 | disabled={initAction.isPending} | |
| 1816 | disabled={!canWriteSourceControl || initAction.isPending} | |
| 1781 | 1817 | onClick={initializeGit} |
| 1782 | 1818 | > |
| 1783 | 1819 | <GitBranchPlusIcon className="size-3.5" aria-hidden /> |
| ... | ... | @@ -1855,7 +1891,7 @@ export default function GitActionsControl({ |
| 1855 | 1891 | size="xs" |
| 1856 | 1892 | part="primary" |
| 1857 | 1893 | panel={isPanel} |
| 1858 | disabled={isGitActionRunning || quickAction.disabled} | |
| 1894 | disabled={!canWriteSourceControl || isGitActionRunning || quickAction.disabled} | |
| 1859 | 1895 | onClick={runQuickAction} |
| 1860 | 1896 | > |
| 1861 | 1897 | <GitQuickActionIcon |
| ... | ... | @@ -2035,6 +2071,8 @@ export default function GitActionsControl({ |
| 2035 | 2071 | <button |
| 2036 | 2072 | type="button" |
| 2037 | 2073 | className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left" |
| 2074 | disabled={!canOperateThread} | |
| 2075 | aria-label={`Open ${file.path} in editor`} | |
| 2038 | 2076 | onClick={() => openChangedFileInEditor(file.path)} |
| 2039 | 2077 | > |
| 2040 | 2078 | <StartTruncatedPath |
| ... | ... | @@ -2102,12 +2140,16 @@ export default function GitActionsControl({ |
| 2102 | 2140 | <Button |
| 2103 | 2141 | variant="outline" |
| 2104 | 2142 | size="sm" |
| 2105 | disabled={noneSelected} | |
| 2143 | disabled={!canChangeThreadBranch || noneSelected} | |
| 2106 | 2144 | onClick={runDialogActionOnNewBranch} |
| 2107 | 2145 | > |
| 2108 | 2146 | Commit on new branch |
| 2109 | 2147 | </Button> |
| 2110 | <Button size="sm" disabled={noneSelected} onClick={runDialogAction}> | |
| 2148 | <Button | |
| 2149 | size="sm" | |
| 2150 | disabled={!canWriteSourceControl || noneSelected} | |
| 2151 | onClick={runDialogAction} | |
| 2152 | > | |
| 2111 | 2153 | Commit |
| 2112 | 2154 | </Button> |
| 2113 | 2155 | </DialogFooter> |
| ... | ... | @@ -2151,6 +2193,7 @@ export default function GitActionsControl({ |
| 2151 | 2193 | variant="outline" |
| 2152 | 2194 | size="sm-multiline" |
| 2153 | 2195 | onClick={continuePendingDefaultBranchAction} |
| 2196 | disabled={!canWriteSourceControl} | |
| 2154 | 2197 | > |
| 2155 | 2198 | {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"} |
| 2156 | 2199 | </Button> |
| ... | ... | @@ -2158,6 +2201,7 @@ export default function GitActionsControl({ |
| 2158 | 2201 | className="w-full max-w-full sm:w-auto" |
| 2159 | 2202 | size="sm-multiline" |
| 2160 | 2203 | onClick={checkoutFeatureBranchAndContinuePendingAction} |
| 2204 | disabled={!canChangeThreadBranch} | |
| 2161 | 2205 | > |
| 2162 | 2206 | Check out feature branch & continue |
| 2163 | 2207 | </Button> |
apps/web/src/components/PullRequestThreadDialog.test.ts created+273| ... | ... | @@ -0,0 +1,273 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | AuthSourceControlWriteScope, | |
| 4 | EnvironmentId, | |
| 5 | ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Cause from "effect/Cause"; | |
| 8 | import { AsyncResult } from "effect/reactivity"; | |
| 9 | import { isValidElement, type ReactNode } from "react"; | |
| 10 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | const state = vi.hoisted(() => ({ | |
| 13 | canOperate: false, | |
| 14 | canWriteSourceControl: true, | |
| 15 | hookValues: [] as unknown[], | |
| 16 | hookIndex: 0, | |
| 17 | tracks: 0, | |
| 18 | resets: 0, | |
| 19 | manager: { operation: null as string | null, error: null, isRunning: false }, | |
| 20 | interrupted: false, | |
| 21 | pendingResponse: undefined as Promise<void> | undefined, | |
| 22 | openChanges: [] as boolean[], | |
| 23 | checkouts: [] as string[], | |
| 24 | setupScripts: 0, | |
| 25 | prepared: [] as { branch: string; worktreePath: string | null }[], | |
| 26 | })); | |
| 27 | ||
| 28 | vi.mock("react", async (importOriginal) => ({ | |
| 29 | ...(await importOriginal<typeof import("react")>()), | |
| 30 | useCallback: (callback: unknown) => callback, | |
| 31 | useMemo: (factory: () => unknown) => factory(), | |
| 32 | useState: (initial: unknown) => { | |
| 33 | const index = state.hookIndex++; | |
| 34 | if (index === state.hookValues.length) state.hookValues.push(initial); | |
| 35 | return [state.hookValues[index], (value: unknown) => (state.hookValues[index] = value)]; | |
| 36 | }, | |
| 37 | useRef: (current: unknown) => ({ current }), | |
| 38 | useEffect: () => {}, | |
| 39 | })); | |
| 40 | vi.mock("@tanstack/react-pacer", () => ({ | |
| 41 | useDebouncedValue: (value: unknown) => [value, { state: { isPending: false } }], | |
| 42 | })); | |
| 43 | vi.mock("~/state/session", () => ({ | |
| 44 | useEnvironmentScope: (_environmentId: unknown, scope: string) => | |
| 45 | scope === AuthOrchestrationOperateScope | |
| 46 | ? state.canOperate | |
| 47 | : scope === AuthSourceControlWriteScope && state.canWriteSourceControl, | |
| 48 | readEnvironmentScope: (_environmentId: unknown, scope: string) => | |
| 49 | scope === AuthOrchestrationOperateScope | |
| 50 | ? state.canOperate | |
| 51 | : scope === AuthSourceControlWriteScope && state.canWriteSourceControl, | |
| 52 | })); | |
| 53 | vi.mock("~/lib/sourceControlActions", async () => { | |
| 54 | const { usePreparePullRequestThreadAction } = await vi.importActual< | |
| 55 | typeof import("~/state/sourceControlActions") | |
| 56 | >("~/state/sourceControlActions"); | |
| 57 | return { | |
| 58 | readCachedPullRequestResolution: () => null, | |
| 59 | usePullRequestResolution: () => ({ | |
| 60 | data: { pullRequest: { number: 123, title: "Pull request", state: "open" } }, | |
| 61 | }), | |
| 62 | usePreparePullRequestThreadAction, | |
| 63 | }; | |
| 64 | }); | |
| 65 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.manager })); | |
| 66 | vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: {} })); | |
| 67 | vi.mock("~/state/use-atom-command", () => ({ | |
| 68 | useAtomCommand: (command: unknown) => command, | |
| 69 | })); | |
| 70 | vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: {} })); | |
| 71 | vi.mock("~/state/git", () => ({ | |
| 72 | gitEnvironment: { | |
| 73 | preparePullRequestThread: async ({ | |
| 74 | input: { mode, threadId }, | |
| 75 | }: { | |
| 76 | input: { mode: string; threadId?: string }; | |
| 77 | }) => { | |
| 78 | state.checkouts.push(mode); | |
| 79 | if (mode === "worktree" && threadId) state.setupScripts += 1; | |
| 80 | await state.pendingResponse; | |
| 81 | if (state.interrupted) return AsyncResult.failure(Cause.interrupt()); | |
| 82 | return AsyncResult.success({ | |
| 83 | branch: "feature/pr", | |
| 84 | worktreePath: mode === "worktree" ? "/worktree" : null, | |
| 85 | }); | |
| 86 | }, | |
| 87 | }, | |
| 88 | })); | |
| 89 | vi.mock("~/lib/utils", () => ({ cn: () => "" })); | |
| 90 | vi.mock("~/state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); | |
| 91 | vi.mock("~/state/vcs", () => ({ | |
| 92 | vcsEnvironment: { status: () => null }, | |
| 93 | vcsActionManager: { | |
| 94 | stateAtom: () => "vcs-state", | |
| 95 | track: (_registry: unknown, _target: unknown, _input: unknown, execute: () => unknown) => { | |
| 96 | state.tracks += 1; | |
| 97 | return execute(); | |
| 98 | }, | |
| 99 | resetError: () => state.resets++, | |
| 100 | }, | |
| 101 | })); | |
| 102 | vi.mock("./ui/button", () => ({ Button: "Button" })); | |
| 103 | vi.mock("./ui/input", () => ({ Input: "Input" })); | |
| 104 | vi.mock("./ui/spinner", () => ({ Spinner: "Spinner" })); | |
| 105 | vi.mock("./ui/dialog", () => ({ | |
| 106 | Dialog: "Dialog", | |
| 107 | DialogDescription: "DialogDescription", | |
| 108 | DialogFooter: "DialogFooter", | |
| 109 | DialogHeader: "DialogHeader", | |
| 110 | DialogPanel: "DialogPanel", | |
| 111 | DialogPopup: "DialogPopup", | |
| 112 | DialogTitle: "DialogTitle", | |
| 113 | })); | |
| 114 | ||
| 115 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | |
| 116 | ||
| 117 | function findAction(node: ReactNode, label: string): (() => unknown) | null { | |
| 118 | if (Array.isArray(node)) { | |
| 119 | for (const child of node) { | |
| 120 | const action = findAction(child, label); | |
| 121 | if (action) return action; | |
| 122 | } | |
| 123 | return null; | |
| 124 | } | |
| 125 | if (!isValidElement<{ children?: ReactNode; onClick?: () => unknown }>(node)) return null; | |
| 126 | if (node.props.children === label) return node.props.onClick ?? null; | |
| 127 | return findAction(node.props.children, label); | |
| 128 | } | |
| 129 | ||
| 130 | function renderDialog() { | |
| 131 | state.hookIndex = 0; | |
| 132 | return PullRequestThreadDialog({ | |
| 133 | open: true, | |
| 134 | environmentId: EnvironmentId.make("environment"), | |
| 135 | threadId: ThreadId.make("thread"), | |
| 136 | cwd: "/repo", | |
| 137 | initialReference: "123", | |
| 138 | onOpenChange: (open) => state.openChanges.push(open), | |
| 139 | onPrepared: (input) => { | |
| 140 | state.prepared.push(input); | |
| 141 | }, | |
| 142 | }); | |
| 143 | } | |
| 144 | ||
| 145 | function prepareAction(label: "Local" | "Worktree") { | |
| 146 | const action = findAction(renderDialog(), label); | |
| 147 | if (!action) throw new Error(`${label} action missing`); | |
| 148 | return action; | |
| 149 | } | |
| 150 | ||
| 151 | function visibleText(node: ReactNode): string { | |
| 152 | if (Array.isArray(node)) return node.map(visibleText).join(" "); | |
| 153 | if (typeof node === "string" || typeof node === "number") return String(node); | |
| 154 | return isValidElement<{ children?: ReactNode }>(node) ? visibleText(node.props.children) : ""; | |
| 155 | } | |
| 156 | ||
| 157 | function deferred<T>() { | |
| 158 | let resolve!: (value: T) => void; | |
| 159 | const promise = new Promise<T>((complete) => { | |
| 160 | resolve = complete; | |
| 161 | }); | |
| 162 | return { promise, resolve }; | |
| 163 | } | |
| 164 | ||
| 165 | describe("pull request worktree permissions", () => { | |
| 166 | beforeEach(() => { | |
| 167 | state.canOperate = false; | |
| 168 | state.canWriteSourceControl = true; | |
| 169 | state.hookValues = []; | |
| 170 | state.hookIndex = 0; | |
| 171 | state.tracks = 0; | |
| 172 | state.resets = 0; | |
| 173 | state.manager = { operation: null, error: null, isRunning: false }; | |
| 174 | state.interrupted = false; | |
| 175 | state.pendingResponse = undefined; | |
| 176 | state.openChanges = []; | |
| 177 | state.checkouts = []; | |
| 178 | state.setupScripts = 0; | |
| 179 | state.prepared = []; | |
| 180 | }); | |
| 181 | ||
| 182 | it("does not prepare a worktree or run setup with only source-control permission", async () => { | |
| 183 | await prepareAction("Worktree")(); | |
| 184 | ||
| 185 | expect(state.checkouts).toEqual([]); | |
| 186 | expect(state.setupScripts).toBe(0); | |
| 187 | expect(state.prepared).toEqual([]); | |
| 188 | }); | |
| 189 | ||
| 190 | it("prepares the worktree and thread when task permission is also granted", async () => { | |
| 191 | state.canOperate = true; | |
| 192 | await prepareAction("Worktree")(); | |
| 193 | ||
| 194 | expect(state.checkouts).toEqual(["worktree"]); | |
| 195 | expect(state.setupScripts).toBe(1); | |
| 196 | expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: "/worktree" }]); | |
| 197 | }); | |
| 198 | ||
| 199 | it("rechecks task permission before invoking a retained worktree action", async () => { | |
| 200 | state.canOperate = true; | |
| 201 | const prepare = prepareAction("Worktree"); | |
| 202 | state.canOperate = false; | |
| 203 | await prepare(); | |
| 204 | ||
| 205 | expect(state.checkouts).toEqual([]); | |
| 206 | expect(state.setupScripts).toBe(0); | |
| 207 | }); | |
| 208 | ||
| 209 | it("keeps local checkout available without task permission", async () => { | |
| 210 | await prepareAction("Local")(); | |
| 211 | ||
| 212 | expect(state.checkouts).toEqual(["local"]); | |
| 213 | expect(state.setupScripts).toBe(0); | |
| 214 | expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]); | |
| 215 | }); | |
| 216 | ||
| 217 | it.each(["Local", "Worktree"] as const)( | |
| 218 | "shows the returned scope denial for a retained %s callback without starting a checkout", | |
| 219 | async (label) => { | |
| 220 | state.canOperate = true; | |
| 221 | state.manager = { operation: "pull", error: null, isRunning: true }; | |
| 222 | const prepare = prepareAction(label); | |
| 223 | state.canWriteSourceControl = false; | |
| 224 | ||
| 225 | await prepare(); | |
| 226 | ||
| 227 | expect(visibleText(renderDialog())).toContain( | |
| 228 | "This connection cannot change source control.", | |
| 229 | ); | |
| 230 | expect(state.checkouts).toEqual([]); | |
| 231 | expect(state.setupScripts).toBe(0); | |
| 232 | expect(state.prepared).toEqual([]); | |
| 233 | expect(state.openChanges).toEqual([]); | |
| 234 | expect(state.tracks).toBe(0); | |
| 235 | expect(state.manager).toEqual({ operation: "pull", error: null, isRunning: true }); | |
| 236 | }, | |
| 237 | ); | |
| 238 | ||
| 239 | it("clears a returned error when a newly authorized retry starts, then completes normally", async () => { | |
| 240 | const prepare = prepareAction("Local"); | |
| 241 | state.canWriteSourceControl = false; | |
| 242 | await prepare(); | |
| 243 | expect(visibleText(renderDialog())).toContain("This connection cannot change source control."); | |
| 244 | ||
| 245 | state.canWriteSourceControl = true; | |
| 246 | const response = deferred<void>(); | |
| 247 | state.pendingResponse = response.promise; | |
| 248 | const completion = prepareAction("Local")(); | |
| 249 | ||
| 250 | expect(visibleText(renderDialog())).not.toContain( | |
| 251 | "This connection cannot change source control.", | |
| 252 | ); | |
| 253 | expect(state.checkouts).toEqual(["local"]); | |
| 254 | expect(state.prepared).toEqual([]); | |
| 255 | response.resolve(undefined); | |
| 256 | await completion; | |
| 257 | ||
| 258 | expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]); | |
| 259 | expect(state.openChanges).toEqual([false]); | |
| 260 | }); | |
| 261 | ||
| 262 | it("keeps interrupted preparations quiet and preserves error reset behavior", async () => { | |
| 263 | state.interrupted = true; | |
| 264 | const before = visibleText(renderDialog()); | |
| 265 | ||
| 266 | await prepareAction("Local")(); | |
| 267 | ||
| 268 | expect(visibleText(renderDialog())).toBe(before); | |
| 269 | expect(state.resets).toBe(1); | |
| 270 | expect(state.prepared).toEqual([]); | |
| 271 | expect(state.openChanges).toEqual([]); | |
| 272 | }); | |
| 273 | }); |
apps/web/src/components/PullRequestThreadDialog.tsx+35-8| ... | ... | @@ -1,5 +1,12 @@ |
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; | |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | type EnvironmentId, | |
| 4 | type ThreadId, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { | |
| 7 | isAtomCommandInterrupted, | |
| 8 | squashAtomCommandFailure, | |
| 9 | } from "@t3tools/client-runtime/state/runtime"; | |
| 3 | 10 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 4 | 11 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | 12 | |
| ... | ... | @@ -12,6 +19,7 @@ import { cn } from "~/lib/utils"; |
| 12 | 19 | import { parsePullRequestReference } from "~/pullRequestReference"; |
| 13 | 20 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; |
| 14 | 21 | import { useEnvironmentQuery } from "~/state/query"; |
| 22 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 15 | 23 | import { vcsEnvironment } from "~/state/vcs"; |
| 16 | 24 | import { Button } from "./ui/button"; |
| 17 | 25 | import { |
| ... | ... | @@ -49,6 +57,7 @@ export function PullRequestThreadDialog({ |
| 49 | 57 | const [reference, setReference] = useState(initialReference ?? ""); |
| 50 | 58 | const [referenceDirty, setReferenceDirty] = useState(false); |
| 51 | 59 | const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); |
| 60 | const [prepareErrorMessage, setPrepareErrorMessage] = useState<string | null>(null); | |
| 52 | 61 | const [debouncedReference, referenceDebouncer] = useDebouncedValue( |
| 53 | 62 | reference, |
| 54 | 63 | { wait: 450 }, |
| ... | ... | @@ -102,6 +111,7 @@ export function PullRequestThreadDialog({ |
| 102 | 111 | ); |
| 103 | 112 | }, [parsedReference, sourceControlScope]); |
| 104 | 113 | const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope); |
| 114 | const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | |
| 105 | 115 | |
| 106 | 116 | const liveResolvedPullRequest = |
| 107 | 117 | parsedReference !== null && parsedReference === parsedDebouncedReference |
| ... | ... | @@ -131,6 +141,13 @@ export function PullRequestThreadDialog({ |
| 131 | 141 | |
| 132 | 142 | const handleConfirm = useCallback( |
| 133 | 143 | async (mode: "local" | "worktree") => { |
| 144 | if (!preparePullRequestThreadAction.isAllowed) return; | |
| 145 | if ( | |
| 146 | mode === "worktree" && | |
| 147 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) | |
| 148 | ) { | |
| 149 | return; | |
| 150 | } | |
| 134 | 151 | if (!parsedReference) { |
| 135 | 152 | setReferenceDirty(true); |
| 136 | 153 | return; |
| ... | ... | @@ -138,6 +155,7 @@ export function PullRequestThreadDialog({ |
| 138 | 155 | if (!parsedReference || !resolvedPullRequest || !cwd) { |
| 139 | 156 | return; |
| 140 | 157 | } |
| 158 | setPrepareErrorMessage(null); | |
| 141 | 159 | setPreparingMode(mode); |
| 142 | 160 | const result = await preparePullRequestThreadAction.run({ |
| 143 | 161 | reference: parsedReference, |
| ... | ... | @@ -148,6 +166,13 @@ export function PullRequestThreadDialog({ |
| 148 | 166 | if (result._tag === "Failure") { |
| 149 | 167 | if (isAtomCommandInterrupted(result)) { |
| 150 | 168 | preparePullRequestThreadAction.resetError(); |
| 169 | } else { | |
| 170 | const error = squashAtomCommandFailure(result); | |
| 171 | setPrepareErrorMessage( | |
| 172 | error instanceof Error | |
| 173 | ? error.message | |
| 174 | : `Failed to prepare ${terminology.singular} thread.`, | |
| 175 | ); | |
| 151 | 176 | } |
| 152 | 177 | return; |
| 153 | 178 | } |
| ... | ... | @@ -159,11 +184,13 @@ export function PullRequestThreadDialog({ |
| 159 | 184 | }, |
| 160 | 185 | [ |
| 161 | 186 | cwd, |
| 187 | environmentId, | |
| 162 | 188 | onOpenChange, |
| 163 | 189 | onPrepared, |
| 164 | 190 | parsedReference, |
| 165 | 191 | preparePullRequestThreadAction, |
| 166 | 192 | resolvedPullRequest, |
| 193 | terminology.singular, | |
| 167 | 194 | threadId, |
| 168 | 195 | ], |
| 169 | 196 | ); |
| ... | ... | @@ -177,6 +204,7 @@ export function PullRequestThreadDialog({ |
| 177 | 204 | : null; |
| 178 | 205 | const errorMessage = |
| 179 | 206 | validationMessage ?? |
| 207 | prepareErrorMessage ?? | |
| 180 | 208 | (resolvedPullRequest === null && pullRequestResolution.error |
| 181 | 209 | ? pullRequestResolution.error |
| 182 | 210 | : preparePullRequestThreadAction.error instanceof Error |
| ... | ... | @@ -273,10 +301,9 @@ export function PullRequestThreadDialog({ |
| 273 | 301 | type="button" |
| 274 | 302 | size="sm" |
| 275 | 303 | variant="outline" |
| 276 | onClick={() => { | |
| 277 | void handleConfirm("local"); | |
| 278 | }} | |
| 304 | onClick={() => handleConfirm("local")} | |
| 279 | 305 | disabled={ |
| 306 | !preparePullRequestThreadAction.isAllowed || | |
| 280 | 307 | !cwd || |
| 281 | 308 | !resolvedPullRequest || |
| 282 | 309 | isResolving || |
| ... | ... | @@ -288,10 +315,10 @@ export function PullRequestThreadDialog({ |
| 288 | 315 | <Button |
| 289 | 316 | type="button" |
| 290 | 317 | size="sm" |
| 291 | onClick={() => { | |
| 292 | void handleConfirm("worktree"); | |
| 293 | }} | |
| 318 | onClick={() => handleConfirm("worktree")} | |
| 294 | 319 | disabled={ |
| 320 | !preparePullRequestThreadAction.isAllowed || | |
| 321 | !canOperateThread || | |
| 295 | 322 | !cwd || |
| 296 | 323 | !resolvedPullRequest || |
| 297 | 324 | isResolving || |
apps/web/src/components/Sidebar.tsx+183-61| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { type EnvironmentId } from "@t3tools/contracts"; | |
| 1 | 2 | import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; |
| 2 | 3 | import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; |
| 3 | 4 | import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators"; |
| ... | ... | @@ -52,6 +53,7 @@ import { |
| 52 | 53 | scopedThreadKey, |
| 53 | 54 | } from "@t3tools/client-runtime/environment"; |
| 54 | 55 | import { |
| 56 | AuthOrchestrationOperateScope, | |
| 55 | 57 | type EnvironmentMachineKind, |
| 56 | 58 | type ScopedThreadRef, |
| 57 | 59 | type ThreadId, |
| ... | ... | @@ -158,7 +160,8 @@ import { vcsEnvironment } from "../state/vcs"; |
| 158 | 160 | import { threadEnvironment } from "../state/threads"; |
| 159 | 161 | import { useEnvironmentQuery } from "../state/query"; |
| 160 | 162 | import { useThreadSearch } from "../state/queries"; |
| 161 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 163 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; | |
| 164 | import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; | |
| 162 | 165 | import { |
| 163 | 166 | buildThreadRouteParams, |
| 164 | 167 | resolveActiveThreadRouteRef, |
| ... | ... | @@ -170,7 +173,11 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 170 | 173 | import { cn } from "~/lib/utils"; |
| 171 | 174 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 172 | 175 | import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; |
| 173 | import { buildDraftActionMenuItems, buildThreadActionMenuItems } from "./threadActionMenu.logic"; | |
| 176 | import { | |
| 177 | buildDraftActionMenuItems, | |
| 178 | buildThreadActionMenuItems, | |
| 179 | threadActionRequiresOperate, | |
| 180 | } from "./threadActionMenu.logic"; | |
| 174 | 181 | import { |
| 175 | 182 | animateSidebarLayoutChanges, |
| 176 | 183 | applySidebarThreadDrop, |
| ... | ... | @@ -296,6 +303,26 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; |
| 296 | 303 | // Module scope keeps the inbox order across routes that unmount the sidebar. |
| 297 | 304 | const inboxReturns = createInboxReturnTracker(); |
| 298 | 305 | |
| 306 | function canOperateThreads( | |
| 307 | threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>, | |
| 308 | ): boolean { | |
| 309 | return threads.every((thread) => | |
| 310 | readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope), | |
| 311 | ); | |
| 312 | } | |
| 313 | ||
| 314 | function checkThreadOperations( | |
| 315 | threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>, | |
| 316 | ): boolean { | |
| 317 | if (canOperateThreads(threads)) return true; | |
| 318 | toastManager.add({ | |
| 319 | type: "error", | |
| 320 | title: "Thread action unavailable", | |
| 321 | description: "This connection cannot change one or more selected threads.", | |
| 322 | }); | |
| 323 | return false; | |
| 324 | } | |
| 325 | ||
| 299 | 326 | function compactSidebarTimeLabel(label: string): string { |
| 300 | 327 | if (label === "just now") return "now"; |
| 301 | 328 | return label.endsWith(" ago") ? label.slice(0, -4) : label; |
| ... | ... | @@ -619,11 +646,13 @@ function SortableThreadRow(props: { |
| 619 | 646 | id: string; |
| 620 | 647 | disabled: boolean; |
| 621 | 648 | contextDrag: boolean; |
| 649 | environmentId: EnvironmentId; | |
| 622 | 650 | children: (bag: SortableThreadRowBag) => ReactNode; |
| 623 | 651 | }) { |
| 652 | const canOperateThread = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope); | |
| 624 | 653 | const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ |
| 625 | 654 | id: props.id, |
| 626 | disabled: { draggable: props.disabled }, | |
| 655 | disabled: { draggable: props.disabled || !canOperateThread }, | |
| 627 | 656 | animateLayoutChanges: animateSidebarLayoutChanges, |
| 628 | 657 | }); |
| 629 | 658 | // dnd-kit memoizes each field but not the bag, so the memoized row would |
| ... | ... | @@ -1173,6 +1202,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1173 | 1202 | [thread.environmentId, thread.id], |
| 1174 | 1203 | ); |
| 1175 | 1204 | const threadKey = scopedThreadKey(threadRef); |
| 1205 | const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); | |
| 1206 | useEffect(() => { | |
| 1207 | if (!canOperateThread && isRenaming) onCancelRename(); | |
| 1208 | }, [canOperateThread, isRenaming, onCancelRename]); | |
| 1176 | 1209 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); |
| 1177 | 1210 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 1178 | 1211 | const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); |
| ... | ... | @@ -1389,7 +1422,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1389 | 1422 | ); |
| 1390 | 1423 | const handleDoubleClick = useCallback( |
| 1391 | 1424 | (event: ReactMouseEvent) => { |
| 1392 | if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { | |
| 1425 | if ( | |
| 1426 | !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) || | |
| 1427 | isRenaming || | |
| 1428 | event.metaKey || | |
| 1429 | event.ctrlKey || | |
| 1430 | event.shiftKey || | |
| 1431 | event.altKey | |
| 1432 | ) { | |
| 1393 | 1433 | return; |
| 1394 | 1434 | } |
| 1395 | 1435 | if ((event.target as HTMLElement).closest("button, a, input")) return; |
| ... | ... | @@ -1497,7 +1537,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1497 | 1537 | // Snooze is offered only where it can succeed: capability-gated and never |
| 1498 | 1538 | // on blocked-on-you work or queued turns (the server rejects both). |
| 1499 | 1539 | const showSnoozeButton = |
| 1500 | props.snoozeSupported && canSnooze(thread, { now: new Date().toISOString() }); | |
| 1540 | canOperateThread && | |
| 1541 | props.snoozeSupported && | |
| 1542 | canSnooze(thread, { now: new Date().toISOString() }); | |
| 1543 | const showHoverActions = | |
| 1544 | (canOperateThread && props.settlementSupported) || showSnoozeButton || hasUnsentDraft; | |
| 1501 | 1545 | // If the thread becomes blocked while the popover is open, the button |
| 1502 | 1546 | // unmounts without firing onOpenChange(false). Deriving the flag keeps a |
| 1503 | 1547 | // stale true from permanently hiding the status label / pinning the |
| ... | ... | @@ -1600,52 +1644,53 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1600 | 1644 | isActive: props.isActive, |
| 1601 | 1645 | }); |
| 1602 | 1646 | |
| 1603 | const title = isRenaming ? ( | |
| 1604 | <input | |
| 1605 | autoFocus | |
| 1606 | value={renamingTitle} | |
| 1607 | aria-label="Thread title" | |
| 1608 | onChange={(event) => onRenameTitleChange(event.target.value)} | |
| 1609 | onFocus={(event) => event.currentTarget.select()} | |
| 1610 | onKeyDown={handleRenameKeyDown} | |
| 1611 | onBlur={handleRenameBlur} | |
| 1612 | onClick={(event) => event.stopPropagation()} | |
| 1613 | onDoubleClick={(event) => event.stopPropagation()} | |
| 1614 | className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" | |
| 1615 | /> | |
| 1616 | ) : ( | |
| 1617 | <span | |
| 1618 | aria-hidden | |
| 1619 | className={cn( | |
| 1620 | "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none", | |
| 1621 | shouldRecede ? "font-normal" : "font-medium", | |
| 1622 | variant === "card" | |
| 1623 | ? cn( | |
| 1624 | "truncate", | |
| 1625 | shouldRecede | |
| 1626 | ? "text-secondary-label" | |
| 1627 | : isUnread || isWoke || status === "input" | |
| 1628 | ? "text-foreground" | |
| 1629 | : status === "failed" | |
| 1630 | ? "text-foreground/95" | |
| 1631 | : "text-foreground/90", | |
| 1632 | ) | |
| 1633 | : cn( | |
| 1634 | "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground", | |
| 1635 | shouldRecede | |
| 1636 | ? "text-secondary-label/70" | |
| 1637 | : props.isActive || isWoke || status === "input" | |
| 1638 | ? "text-foreground" | |
| 1639 | : isUnread | |
| 1640 | ? "text-muted-foreground" | |
| 1641 | : "text-secondary-label/70", | |
| 1642 | ), | |
| 1643 | isRegeneratingTitle && "opacity-55", | |
| 1644 | )} | |
| 1645 | > | |
| 1646 | {thread.title} | |
| 1647 | </span> | |
| 1648 | ); | |
| 1647 | const title = | |
| 1648 | isRenaming && canOperateThread ? ( | |
| 1649 | <input | |
| 1650 | autoFocus | |
| 1651 | value={renamingTitle} | |
| 1652 | aria-label="Thread title" | |
| 1653 | onChange={(event) => onRenameTitleChange(event.target.value)} | |
| 1654 | onFocus={(event) => event.currentTarget.select()} | |
| 1655 | onKeyDown={handleRenameKeyDown} | |
| 1656 | onBlur={handleRenameBlur} | |
| 1657 | onClick={(event) => event.stopPropagation()} | |
| 1658 | onDoubleClick={(event) => event.stopPropagation()} | |
| 1659 | className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" | |
| 1660 | /> | |
| 1661 | ) : ( | |
| 1662 | <span | |
| 1663 | aria-hidden | |
| 1664 | className={cn( | |
| 1665 | "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none", | |
| 1666 | shouldRecede ? "font-normal" : "font-medium", | |
| 1667 | variant === "card" | |
| 1668 | ? cn( | |
| 1669 | "truncate", | |
| 1670 | shouldRecede | |
| 1671 | ? "text-secondary-label" | |
| 1672 | : isUnread || isWoke || status === "input" | |
| 1673 | ? "text-foreground" | |
| 1674 | : status === "failed" | |
| 1675 | ? "text-foreground/95" | |
| 1676 | : "text-foreground/90", | |
| 1677 | ) | |
| 1678 | : cn( | |
| 1679 | "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground", | |
| 1680 | shouldRecede | |
| 1681 | ? "text-secondary-label/70" | |
| 1682 | : props.isActive || isWoke || status === "input" | |
| 1683 | ? "text-foreground" | |
| 1684 | : isUnread | |
| 1685 | ? "text-muted-foreground" | |
| 1686 | : "text-secondary-label/70", | |
| 1687 | ), | |
| 1688 | isRegeneratingTitle && "opacity-55", | |
| 1689 | )} | |
| 1690 | > | |
| 1691 | {thread.title} | |
| 1692 | </span> | |
| 1693 | ); | |
| 1649 | 1694 | const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>; |
| 1650 | 1695 | |
| 1651 | 1696 | // Stacks show their layer count; multiple unrelated links show their total count. |
| ... | ... | @@ -1706,7 +1751,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1706 | 1751 | const showPin = |
| 1707 | 1752 | props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); |
| 1708 | 1753 | const pinIndicator = showPin ? ( |
| 1709 | props.pinningSupported && !sortable?.isDragging ? ( | |
| 1754 | props.pinningSupported && canOperateThread && !sortable?.isDragging ? ( | |
| 1710 | 1755 | <Tooltip> |
| 1711 | 1756 | <TooltipTrigger |
| 1712 | 1757 | render={ |
| ... | ... | @@ -2350,7 +2395,7 @@ export default function Sidebar() { |
| 2350 | 2395 | archiveThread, |
| 2351 | 2396 | deleteThread, |
| 2352 | 2397 | } = useThreadActions(); |
| 2353 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | |
| 2398 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { | |
| 2354 | 2399 | reportFailure: false, |
| 2355 | 2400 | }); |
| 2356 | 2401 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ |
| ... | ... | @@ -3180,6 +3225,7 @@ export default function Sidebar() { |
| 3180 | 3225 | const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null); |
| 3181 | 3226 | const [renamingTitle, setRenamingTitle] = useState(""); |
| 3182 | 3227 | const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { |
| 3228 | if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; | |
| 3183 | 3229 | setRenamingThreadKey(scopedThreadKey(threadRef)); |
| 3184 | 3230 | setRenamingTitle(title); |
| 3185 | 3231 | }, []); |
| ... | ... | @@ -3187,6 +3233,7 @@ export default function Sidebar() { |
| 3187 | 3233 | const commitThreadRename = useCallback( |
| 3188 | 3234 | (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { |
| 3189 | 3235 | void (async () => { |
| 3236 | if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; | |
| 3190 | 3237 | const trimmed = title.trim(); |
| 3191 | 3238 | setRenamingThreadKey(null); |
| 3192 | 3239 | if (trimmed.length === 0) { |
| ... | ... | @@ -3960,6 +4007,17 @@ export default function Sidebar() { |
| 3960 | 4007 | : plan.kind === "reorder-pinned" || plan.kind === "move-active" |
| 3961 | 4008 | ? plan.assignments |
| 3962 | 4009 | : []; |
| 4010 | // A drop rewrites the dragged row and every row it renumbers, so all of | |
| 4011 | // them must be writable before any of them changes. | |
| 4012 | if ( | |
| 4013 | !checkThreadOperations( | |
| 4014 | [activeKey, ...assignments.map(({ id }) => id)].flatMap((key) => { | |
| 4015 | const thread = threadByKey.get(key); | |
| 4016 | return thread ? [thread] : []; | |
| 4017 | }), | |
| 4018 | ) | |
| 4019 | ) | |
| 4020 | return; | |
| 3963 | 4021 | const drop = { |
| 3964 | 4022 | key: activeKey, |
| 3965 | 4023 | sourceSection: activeSection, |
| ... | ... | @@ -4174,6 +4232,9 @@ export default function Sidebar() { |
| 4174 | 4232 | const thread = threadByKeyRef.current.get(threadKey); |
| 4175 | 4233 | return thread ? [thread] : []; |
| 4176 | 4234 | }); |
| 4235 | const settlingThreads = selectedThreads.filter( | |
| 4236 | (thread) => thread.settledOverride !== "settled", | |
| 4237 | ); | |
| 4177 | 4238 | const canSnoozeSelection = selectedThreads.every( |
| 4178 | 4239 | (thread) => |
| 4179 | 4240 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && |
| ... | ... | @@ -4206,31 +4267,73 @@ export default function Sidebar() { |
| 4206 | 4267 | const clicked = await settlePromise(() => |
| 4207 | 4268 | api.contextMenu.show( |
| 4208 | 4269 | [ |
| 4209 | ...(unpinMenuItem ? [unpinMenuItem] : []), | |
| 4210 | { id: "settle", label: `Settle (${count})` }, | |
| 4270 | ...(unpinMenuItem | |
| 4271 | ? [{ ...unpinMenuItem, disabled: !canOperateThreads(pinnedSelectedThreads) }] | |
| 4272 | : []), | |
| 4273 | { | |
| 4274 | id: "settle", | |
| 4275 | label: `Settle (${count})`, | |
| 4276 | disabled: settlingThreads.length === 0 || !canOperateThreads(settlingThreads), | |
| 4277 | }, | |
| 4211 | 4278 | ...(canSnoozeSelection |
| 4212 | 4279 | ? [ |
| 4213 | 4280 | { |
| 4214 | 4281 | id: "snooze", |
| 4215 | 4282 | label: `Snooze (${count})`, |
| 4283 | disabled: !canOperateThreads(selectedThreads), | |
| 4216 | 4284 | children: [ |
| 4217 | 4285 | ...snoozePresets.map((preset) => ({ |
| 4218 | 4286 | id: `snooze:${preset.id}`, |
| 4219 | 4287 | label: `${preset.label} (${preset.whenLabel})`, |
| 4288 | disabled: !canOperateThreads(selectedThreads), | |
| 4220 | 4289 | })), |
| 4221 | { id: "snooze:custom", label: "Custom…", separatorBefore: true }, | |
| 4290 | { | |
| 4291 | id: "snooze:custom", | |
| 4292 | label: "Custom…", | |
| 4293 | separatorBefore: true, | |
| 4294 | disabled: !canOperateThreads(selectedThreads), | |
| 4295 | }, | |
| 4222 | 4296 | ], |
| 4223 | 4297 | }, |
| 4224 | 4298 | ] |
| 4225 | 4299 | : []), |
| 4226 | ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), | |
| 4300 | ...(titleRegenerationMenuItem | |
| 4301 | ? [ | |
| 4302 | { | |
| 4303 | ...titleRegenerationMenuItem, | |
| 4304 | disabled: | |
| 4305 | titleRegenerationMenuItem.disabled || | |
| 4306 | !canOperateThreads(regeneratableTitleThreads), | |
| 4307 | }, | |
| 4308 | ] | |
| 4309 | : []), | |
| 4227 | 4310 | { id: "mark-unread", label: `Mark unread (${count})` }, |
| 4228 | { id: "delete", label: `Delete (${count})`, destructive: true }, | |
| 4311 | { | |
| 4312 | id: "delete", | |
| 4313 | label: `Delete (${count})`, | |
| 4314 | destructive: true, | |
| 4315 | disabled: !canOperateThreads(selectedThreads), | |
| 4316 | }, | |
| 4229 | 4317 | ], |
| 4230 | 4318 | position, |
| 4231 | 4319 | ), |
| 4232 | 4320 | ); |
| 4233 | if (clicked._tag === "Failure") return; | |
| 4321 | if (clicked._tag === "Failure" || clicked.value === null) return; | |
| 4322 | const actionTargets = | |
| 4323 | clicked.value === "unpin" | |
| 4324 | ? pinnedSelectedThreads | |
| 4325 | : clicked.value === "regenerate-title" | |
| 4326 | ? regeneratableTitleThreads | |
| 4327 | : clicked.value === "settle" | |
| 4328 | ? settlingThreads.flatMap((thread) => { | |
| 4329 | const current = threadByKeyRef.current.get( | |
| 4330 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 4331 | ); | |
| 4332 | return current && current.settledOverride !== "settled" ? [current] : []; | |
| 4333 | }) | |
| 4334 | : selectedThreads; | |
| 4335 | if (clicked.value === "settle" && actionTargets.length === 0) return; | |
| 4336 | if (clicked.value !== "mark-unread" && !checkThreadOperations(actionTargets)) return; | |
| 4234 | 4337 | if (clicked.value?.startsWith("snooze:")) { |
| 4235 | 4338 | const preset = |
| 4236 | 4339 | clicked.value === "snooze:custom" |
| ... | ... | @@ -4303,7 +4406,11 @@ export default function Sidebar() { |
| 4303 | 4406 | return; |
| 4304 | 4407 | } |
| 4305 | 4408 | if (clicked.value === "settle") { |
| 4306 | settleThreads(threadKeys); | |
| 4409 | settleThreads( | |
| 4410 | actionTargets.map((thread) => | |
| 4411 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | |
| 4412 | ), | |
| 4413 | ); | |
| 4307 | 4414 | clearSelection(); |
| 4308 | 4415 | return; |
| 4309 | 4416 | } |
| ... | ... | @@ -4328,6 +4435,15 @@ export default function Sidebar() { |
| 4328 | 4435 | ); |
| 4329 | 4436 | if (confirmed._tag === "Failure" || !confirmed.value) return; |
| 4330 | 4437 | } |
| 4438 | if ( | |
| 4439 | !checkThreadOperations( | |
| 4440 | threadKeys.flatMap((threadKey) => { | |
| 4441 | const thread = threadByKeyRef.current.get(threadKey); | |
| 4442 | return thread ? [thread] : []; | |
| 4443 | }), | |
| 4444 | ) | |
| 4445 | ) | |
| 4446 | return; | |
| 4331 | 4447 | const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ |
| 4332 | 4448 | entries: threadKeys.map((threadKey) => ({ threadKey })), |
| 4333 | 4449 | delete: async ({ threadKey }, deletedThreadKeys) => { |
| ... | ... | @@ -4469,6 +4585,10 @@ export default function Sidebar() { |
| 4469 | 4585 | const clicked = await settlePromise(() => |
| 4470 | 4586 | api.contextMenu.show( |
| 4471 | 4587 | buildThreadActionMenuItems({ |
| 4588 | canOperate: readEnvironmentScope( | |
| 4589 | threadRef.environmentId, | |
| 4590 | AuthOrchestrationOperateScope, | |
| 4591 | ), | |
| 4472 | 4592 | branch: thread.branch ?? null, |
| 4473 | 4593 | projectFilter: threadProjectGroup |
| 4474 | 4594 | ? { |
| ... | ... | @@ -4495,7 +4615,8 @@ export default function Sidebar() { |
| 4495 | 4615 | position, |
| 4496 | 4616 | ), |
| 4497 | 4617 | ); |
| 4498 | if (clicked._tag === "Failure") return; | |
| 4618 | if (clicked._tag === "Failure" || clicked.value === null) return; | |
| 4619 | if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return; | |
| 4499 | 4620 | if (clicked.value?.startsWith("snooze:")) { |
| 4500 | 4621 | const preset = |
| 4501 | 4622 | clicked.value === "snooze:custom" |
| ... | ... | @@ -5228,6 +5349,7 @@ export default function Sidebar() { |
| 5228 | 5349 | key={threadKey} |
| 5229 | 5350 | id={threadKey} |
| 5230 | 5351 | contextDrag={isContextDrag} |
| 5352 | environmentId={thread.environmentId} | |
| 5231 | 5353 | disabled={ |
| 5232 | 5354 | renamingThreadKey === threadKey || |
| 5233 | 5355 | section === "working" || |
apps/web/src/components/chat/ChatHeader.tsx+30-7| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import { type EnvironmentId, type ThreadId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | type EnvironmentId, | |
| 4 | type ThreadId, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 3 | 7 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 4 | 8 | import { |
| ... | ... | @@ -23,6 +27,8 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; |
| 23 | 27 | import { readLocalApi } from "~/localApi"; |
| 24 | 28 | import { threadEnvironment } from "../../state/threads"; |
| 25 | 29 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 30 | import { useOrchestrationCommand } from "../../state/use-orchestration-command"; | |
| 31 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | |
| 26 | 32 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 27 | 33 | import { |
| 28 | 34 | WorkspaceBreadcrumb, |
| ... | ... | @@ -81,7 +87,11 @@ export const ChatHeader = memo(function ChatHeader({ |
| 81 | 87 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), |
| 82 | 88 | [activeThreadEnvironmentId, activeThreadId], |
| 83 | 89 | ); |
| 84 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | |
| 90 | const canOperateThread = useEnvironmentScope( | |
| 91 | activeThreadEnvironmentId, | |
| 92 | AuthOrchestrationOperateScope, | |
| 93 | ); | |
| 94 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { | |
| 85 | 95 | reportFailure: false, |
| 86 | 96 | }); |
| 87 | 97 | // Inline rename, keyed by thread: navigating away drops an in-progress |
| ... | ... | @@ -94,23 +104,36 @@ export const ChatHeader = memo(function ChatHeader({ |
| 94 | 104 | } | null>(null); |
| 95 | 105 | if ( |
| 96 | 106 | renaming !== null && |
| 97 | (renaming.threadId !== activeThreadId || renaming.environmentId !== activeThreadEnvironmentId) | |
| 107 | (renaming.threadId !== activeThreadId || | |
| 108 | renaming.environmentId !== activeThreadEnvironmentId || | |
| 109 | !canOperateThread) | |
| 98 | 110 | ) { |
| 99 | 111 | setRenaming(null); |
| 100 | 112 | } |
| 101 | const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null; | |
| 113 | const renamingTitle = | |
| 114 | canOperateThread && | |
| 115 | renaming?.threadId === activeThreadId && | |
| 116 | renaming.environmentId === activeThreadEnvironmentId | |
| 117 | ? renaming.title | |
| 118 | : null; | |
| 102 | 119 | const renameCommittedRef = useRef(false); |
| 103 | 120 | const startRename = useCallback(() => { |
| 121 | if ( | |
| 122 | !isServerThread || | |
| 123 | !readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope) | |
| 124 | ) | |
| 125 | return; | |
| 104 | 126 | renameCommittedRef.current = false; |
| 105 | 127 | setRenaming({ |
| 106 | threadId: activeThreadId, | |
| 107 | 128 | environmentId: activeThreadEnvironmentId, |
| 129 | threadId: activeThreadId, | |
| 108 | 130 | title: activeThreadTitle, |
| 109 | 131 | }); |
| 110 | }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle]); | |
| 132 | }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, isServerThread]); | |
| 111 | 133 | const commitRename = useCallback( |
| 112 | 134 | (title: string) => { |
| 113 | 135 | setRenaming(null); |
| 136 | if (!readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)) return; | |
| 114 | 137 | const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); |
| 115 | 138 | if (resolution.action === "reject-empty") { |
| 116 | 139 | toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); |
| ... | ... | @@ -303,7 +326,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 303 | 326 | aria-label={`Thread actions for ${activeThreadTitle}`} |
| 304 | 327 | aria-haspopup="menu" |
| 305 | 328 | onClick={openMenuFromTitle} |
| 306 | onDoubleClick={handleTitleDoubleClick} | |
| 329 | onDoubleClick={canOperateThread ? handleTitleDoubleClick : undefined} | |
| 307 | 330 | onBlur={cancelPendingTitleMenu} |
| 308 | 331 | className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" |
| 309 | 332 | /> |
apps/web/src/components/pullRequest/PullRequestCommentForm.tsx+4-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 1 | 2 | /** |
| 2 | 3 | * The comment half of the floating composer: a remark on the pull request itself, optionally |
| 3 | 4 | * the one that closes or reopens it. The popover around it belongs to PullRequestComposer. |
| ... | ... | @@ -6,7 +7,6 @@ import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3to |
| 6 | 7 | import { SendIcon } from "lucide-react"; |
| 7 | 8 | import { useState, type RefObject } from "react"; |
| 8 | 9 | |
| 9 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 10 | 10 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 11 | 11 | |
| 12 | 12 | import { Button } from "../ui/button"; |
| ... | ... | @@ -38,7 +38,9 @@ export function PullRequestCommentForm({ |
| 38 | 38 | }) { |
| 39 | 39 | const [body, setBody] = useState(""); |
| 40 | 40 | const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); |
| 41 | const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); | |
| 41 | const postComment = useAtomCommand(pullRequestEnvironment.comment, { | |
| 42 | reportFailure: false, | |
| 43 | }); | |
| 42 | 44 | const followUpAction = |
| 43 | 45 | detail.state === "open" && |
| 44 | 46 | detail.capabilities.actions.includes("close") && |
apps/web/src/components/pullRequest/PullRequestDetailPanel.test.tsx+6-1| ... | ... | @@ -24,6 +24,11 @@ const { newThread, prepareThread, refresh, Wrapper, Trigger } = vi.hoisted(() => |
| 24 | 24 | </> |
| 25 | 25 | ), |
| 26 | 26 | })); |
| 27 | vi.mock("~/state/session", async (original) => ({ | |
| 28 | ...(await original<typeof import("~/state/session")>()), | |
| 29 | useEnvironmentScope: () => true, | |
| 30 | readEnvironmentScope: () => true, | |
| 31 | })); | |
| 27 | 32 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); |
| 28 | 33 | vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); |
| 29 | 34 | vi.mock("~/state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map() })); |
| ... | ... | @@ -39,7 +44,7 @@ vi.mock("~/hooks/useSettings", () => ({ |
| 39 | 44 | vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} })); |
| 40 | 45 | vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => newThread })); |
| 41 | 46 | vi.mock("~/lib/sourceControlActions", () => ({ |
| 42 | usePreparePullRequestThreadAction: () => ({ run: prepareThread }), | |
| 47 | usePreparePullRequestThreadAction: () => ({ run: prepareThread, isAllowed: true, error: null }), | |
| 43 | 48 | })); |
| 44 | 49 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); |
| 45 | 50 | vi.mock("~/state/pullRequests", async (importOriginal) => ({ |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+59-12| ... | ... | @@ -5,6 +5,8 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 5 | 5 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 6 | 6 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 7 | 7 | import { |
| 8 | AuthOrchestrationOperateScope, | |
| 9 | AuthSourceControlWriteScope, | |
| 8 | 10 | type EnvironmentId, |
| 9 | 11 | type PullRequestAction, |
| 10 | 12 | type PullRequestMergeMethod, |
| ... | ... | @@ -70,6 +72,7 @@ import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 70 | 72 | import { useProjects, useServerConfigs } from "~/state/entities"; |
| 71 | 73 | import { useEnvironments } from "~/state/environments"; |
| 72 | 74 | import { useEnvironmentQuery } from "~/state/query"; |
| 75 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 73 | 76 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 74 | 77 | import { |
| 75 | 78 | pullRequestEnvironment, |
| ... | ... | @@ -578,6 +581,7 @@ export function PullRequestDetailPanel({ |
| 578 | 581 | // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself |
| 579 | 582 | // alone. One at a time whatever the key: they all check the same pull request out. |
| 580 | 583 | const [handoff, setHandoff] = useState<string | null>(null); |
| 584 | const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); | |
| 581 | 585 | const detailQuery = useEnvironmentQuery( |
| 582 | 586 | pullRequestEnvironment.detail({ environmentId, input: reference }), |
| 583 | 587 | ); |
| ... | ... | @@ -690,6 +694,25 @@ export function PullRequestDetailPanel({ |
| 690 | 694 | ? null |
| 691 | 695 | : { |
| 692 | 696 | ...coreDetail, |
| 697 | capabilities: canWriteSourceControl | |
| 698 | ? coreDetail.capabilities | |
| 699 | : { | |
| 700 | ...coreDetail.capabilities, | |
| 701 | reactions: false, | |
| 702 | edit: { changeRequest: false, comment: false }, | |
| 703 | }, | |
| 704 | viewerPermissions: canWriteSourceControl | |
| 705 | ? coreDetail.viewerPermissions | |
| 706 | : { | |
| 707 | ...coreDetail.viewerPermissions, | |
| 708 | actions: [], | |
| 709 | comment: false, | |
| 710 | resolve: false, | |
| 711 | verdicts: [], | |
| 712 | requestReviewers: false, | |
| 713 | updateMethods: [], | |
| 714 | labels: false, | |
| 715 | }, | |
| 693 | 716 | author: activity?.author ?? coreDetail.author, |
| 694 | 717 | reviewers: activity?.reviewers ?? coreDetail.reviewers, |
| 695 | 718 | comments: activity?.comments ?? [], |
| ... | ... | @@ -699,7 +722,7 @@ export function PullRequestDetailPanel({ |
| 699 | 722 | commits: activity?.commits ?? [], |
| 700 | 723 | reactions: activity?.reactions ?? [], |
| 701 | 724 | }, |
| 702 | [activity, coreDetail], | |
| 725 | [activity, canWriteSourceControl, coreDetail], | |
| 703 | 726 | ); |
| 704 | 727 | const handoffSummary = detail ?? sharedSummary; |
| 705 | 728 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| ... | ... | @@ -857,8 +880,12 @@ export function PullRequestDetailPanel({ |
| 857 | 880 | appliedForcedToken.current = forcedRefreshToken; |
| 858 | 881 | void refreshFromHost(); |
| 859 | 882 | }, [forcedRefreshToken, refreshFromHost]); |
| 860 | const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); | |
| 861 | const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); | |
| 883 | const runAction = useAtomCommand(pullRequestEnvironment.runAction, { | |
| 884 | reportFailure: false, | |
| 885 | }); | |
| 886 | const postComment = useAtomCommand(pullRequestEnvironment.comment, { | |
| 887 | reportFailure: false, | |
| 888 | }); | |
| 862 | 889 | // Which action is in flight, not merely that one is: every control here is disabled while any |
| 863 | 890 | // of them runs, but only the button that was pressed may say what it is doing. |
| 864 | 891 | const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null); |
| ... | ... | @@ -912,6 +939,7 @@ export function PullRequestDetailPanel({ |
| 912 | 939 | const actingEnvironmentId = acting?.environmentId ?? environmentId; |
| 913 | 940 | const checkoutRoot = |
| 914 | 941 | acting?.workspaceRoot ?? detail?.workspaceRoot ?? project?.workspaceRoot ?? null; |
| 942 | const canOperateThread = useEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope); | |
| 915 | 943 | const prepareThread = usePreparePullRequestThreadAction({ |
| 916 | 944 | environmentId: actingEnvironmentId, |
| 917 | 945 | cwd: checkoutRoot, |
| ... | ... | @@ -973,13 +1001,13 @@ export function PullRequestDetailPanel({ |
| 973 | 1001 | method?: PullRequestMergeMethod, |
| 974 | 1002 | updateMethod?: PullRequestUpdateMethod, |
| 975 | 1003 | ) => { |
| 976 | if (pendingAction !== null) return false; | |
| 1004 | if (!canWriteSourceControl || pendingAction !== null) return false; | |
| 977 | 1005 | setPendingAction(action); |
| 978 | 1006 | return finishAction(action, method, updateMethod); |
| 979 | 1007 | }; |
| 980 | 1008 | |
| 981 | 1009 | const performCommentAction = async (body: string, action: "close" | "reopen") => { |
| 982 | if (pendingAction !== null) return { commentPosted: false }; | |
| 1010 | if (!canWriteSourceControl || pendingAction !== null) return { commentPosted: false }; | |
| 983 | 1011 | setPendingAction(action); |
| 984 | 1012 | const commentResult = await postComment({ |
| 985 | 1013 | environmentId, |
| ... | ... | @@ -999,7 +1027,7 @@ export function PullRequestDetailPanel({ |
| 999 | 1027 | |
| 1000 | 1028 | const saveTitle = async (next: string) => { |
| 1001 | 1029 | const title = next.trim(); |
| 1002 | if (detail === null || titleSaving) return; | |
| 1030 | if (!canWriteSourceControl || detail === null || titleSaving) return; | |
| 1003 | 1031 | if (title.length === 0 || title === detail.title) { |
| 1004 | 1032 | setTitleScope(null); |
| 1005 | 1033 | return; |
| ... | ... | @@ -1030,6 +1058,8 @@ export function PullRequestDetailPanel({ |
| 1030 | 1058 | }; |
| 1031 | 1059 | |
| 1032 | 1060 | const attachTarget = composerDraftTarget ?? null; |
| 1061 | const canPrepareWorktree = prepareThread.isAllowed && canOperateThread; | |
| 1062 | const canFixFindings = attachTarget !== null || canPrepareWorktree; | |
| 1033 | 1063 | const handoffLabels = pullRequestHandoffLabels(attachTarget !== null); |
| 1034 | 1064 | |
| 1035 | 1065 | const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => { |
| ... | ... | @@ -1158,6 +1188,18 @@ export function PullRequestDetailPanel({ |
| 1158 | 1188 | return; |
| 1159 | 1189 | } |
| 1160 | 1190 | if (checkoutRoot === null) return; |
| 1191 | if ( | |
| 1192 | !prepareThread.isAllowed || | |
| 1193 | !readEnvironmentScope(actingEnvironmentId, AuthSourceControlWriteScope) | |
| 1194 | ) { | |
| 1195 | return; | |
| 1196 | } | |
| 1197 | if ( | |
| 1198 | mode === "worktree" && | |
| 1199 | !readEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope) | |
| 1200 | ) { | |
| 1201 | return; | |
| 1202 | } | |
| 1161 | 1203 | setHandoff(kind); |
| 1162 | 1204 | // The menu closes on the press and takes its "Preparing..." label with it, so this is the |
| 1163 | 1205 | // only thing answering for the checkout. It carries no timeout of its own: a loading toast |
| ... | ... | @@ -1528,7 +1570,7 @@ export function PullRequestDetailPanel({ |
| 1528 | 1570 | <TooltipPopup>Check out this pull request</TooltipPopup> |
| 1529 | 1571 | </Tooltip> |
| 1530 | 1572 | <MenuPopup align="end" side="bottom"> |
| 1531 | <MenuItem onClick={() => startCheckout("worktree")}> | |
| 1573 | <MenuItem disabled={!canPrepareWorktree} onClick={() => startCheckout("worktree")}> | |
| 1532 | 1574 | <GitBranchIcon className="mt-1 size-3.5 shrink-0 self-start" /> |
| 1533 | 1575 | <span className="flex min-w-0 flex-col"> |
| 1534 | 1576 | <span>In a separate worktree</span> |
| ... | ... | @@ -1537,7 +1579,7 @@ export function PullRequestDetailPanel({ |
| 1537 | 1579 | </span> |
| 1538 | 1580 | </span> |
| 1539 | 1581 | </MenuItem> |
| 1540 | <MenuItem onClick={() => startCheckout("local")}> | |
| 1582 | <MenuItem disabled={!prepareThread.isAllowed} onClick={() => startCheckout("local")}> | |
| 1541 | 1583 | <FolderGit2Icon className="mt-1 size-3.5 shrink-0 self-start" /> |
| 1542 | 1584 | <span className="flex min-w-0 flex-col"> |
| 1543 | 1585 | <span>In this repository</span> |
| ... | ... | @@ -2016,7 +2058,10 @@ export function PullRequestDetailPanel({ |
| 2016 | 2058 | </span> |
| 2017 | 2059 | </span> |
| 2018 | 2060 | </MenuItem> |
| 2019 | <MenuItem disabled={handoff !== null} onClick={startFixFindings}> | |
| 2061 | <MenuItem | |
| 2062 | disabled={handoff !== null || !canFixFindings} | |
| 2063 | onClick={startFixFindings} | |
| 2064 | > | |
| 2020 | 2065 | <HammerIcon className="size-3.5" /> |
| 2021 | 2066 | {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings} |
| 2022 | 2067 | </MenuItem> |
| ... | ... | @@ -2383,7 +2428,9 @@ export function PullRequestDetailPanel({ |
| 2383 | 2428 | <Button |
| 2384 | 2429 | size="xs" |
| 2385 | 2430 | variant="outline" |
| 2386 | disabled={titleSaving || titleDraft.trim().length === 0} | |
| 2431 | disabled={ | |
| 2432 | !canWriteSourceControl || titleSaving || titleDraft.trim().length === 0 | |
| 2433 | } | |
| 2387 | 2434 | onClick={() => void saveTitle(titleDraft)} |
| 2388 | 2435 | > |
| 2389 | 2436 | {titleSaving ? "Saving..." : "Save"} |
| ... | ... | @@ -2706,7 +2753,7 @@ export function PullRequestDetailPanel({ |
| 2706 | 2753 | pendingFinding={handoff} |
| 2707 | 2754 | fixFindingLabel={handoffLabels.fixFinding} |
| 2708 | 2755 | fixCheckLabel={handoffLabels.fixCheck} |
| 2709 | onFixFinding={startFixFinding} | |
| 2756 | {...(canFixFindings ? { onFixFinding: startFixFinding } : {})} | |
| 2710 | 2757 | onRefresh={refreshDetail} |
| 2711 | 2758 | onRefreshChecks={refreshFromHost} |
| 2712 | 2759 | /> |
| ... | ... | @@ -2746,7 +2793,7 @@ export function PullRequestDetailPanel({ |
| 2746 | 2793 | onSelectedCommitChange={selectCodeCommit} |
| 2747 | 2794 | pendingFinding={handoff} |
| 2748 | 2795 | fixFindingLabel={handoffLabels.fixFinding} |
| 2749 | onFixFinding={startFixFinding} | |
| 2796 | {...(canFixFindings ? { onFixFinding: startFixFinding } : {})} | |
| 2750 | 2797 | onRefresh={refreshDetail} |
| 2751 | 2798 | refreshToken={codeRefreshToken} |
| 2752 | 2799 | /> |
apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx+4-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 1 | 2 | /** |
| 2 | 3 | * Putting a label on, and taking one off, from the row that says which it already wears. |
| 3 | 4 | * |
| ... | ... | @@ -11,7 +12,6 @@ import { useMemo, useState } from "react"; |
| 11 | 12 | |
| 12 | 13 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 13 | 14 | import { useEnvironmentQuery } from "~/state/query"; |
| 14 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 15 | 15 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 16 | 16 | |
| 17 | 17 | import { toastManager } from "../ui/toast"; |
| ... | ... | @@ -48,7 +48,9 @@ export function PullRequestLabelPicker({ |
| 48 | 48 | const candidatesQuery = useEnvironmentQuery( |
| 49 | 49 | open ? pullRequestEnvironment.labelCandidates({ environmentId, input: reference }) : null, |
| 50 | 50 | ); |
| 51 | const setLabels = useAtomCommand(pullRequestEnvironment.setLabels, { reportFailure: false }); | |
| 51 | const setLabels = useAtomCommand(pullRequestEnvironment.setLabels, { | |
| 52 | reportFailure: false, | |
| 53 | }); | |
| 52 | 54 | |
| 53 | 55 | const candidates = useMemo( |
| 54 | 56 | () => (candidatesQuery.data?.candidates ?? []).filter((entry) => matches(entry, query)), |
apps/web/src/components/pullRequest/PullRequestMarkdownEditor.test.tsx created+148| ... | ... | @@ -0,0 +1,148 @@ |
| 1 | import { AuthSourceControlWriteScope, EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { act } from "react"; | |
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 4 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | const permissions = vi.hoisted(() => ({ | |
| 7 | allowed: new Set<string>(), | |
| 8 | listeners: new Set<() => void>(), | |
| 9 | })); | |
| 10 | ||
| 11 | vi.mock("~/state/session", async () => { | |
| 12 | const { useSyncExternalStore } = await import("react"); | |
| 13 | const canWrite = (environmentId: string, scope: string) => | |
| 14 | scope === AuthSourceControlWriteScope && permissions.allowed.has(environmentId); | |
| 15 | return { | |
| 16 | readEnvironmentScope: canWrite, | |
| 17 | useEnvironmentScope: (environmentId: string, scope: string) => | |
| 18 | useSyncExternalStore( | |
| 19 | (listener) => { | |
| 20 | permissions.listeners.add(listener); | |
| 21 | return () => permissions.listeners.delete(listener); | |
| 22 | }, | |
| 23 | () => canWrite(environmentId, scope), | |
| 24 | ), | |
| 25 | }; | |
| 26 | }); | |
| 27 | vi.mock("~/lib/utils", () => ({ cn: () => "" })); | |
| 28 | vi.mock("../ui/button", () => ({ Button: "button" })); | |
| 29 | vi.mock("../ui/textarea", () => ({ Textarea: "textarea" })); | |
| 30 | vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); | |
| 31 | vi.mock("./PullRequestMarkdown", () => ({ PullRequestMarkdown: "article" })); | |
| 32 | ||
| 33 | import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; | |
| 34 | ||
| 35 | const primary = EnvironmentId.make("primary"); | |
| 36 | const secondary = EnvironmentId.make("secondary"); | |
| 37 | let renderer: ReactTestRenderer | undefined; | |
| 38 | const onSave = vi.fn<(next: string) => void>(); | |
| 39 | const onCancel = vi.fn<() => void>(); | |
| 40 | ||
| 41 | beforeEach(() => { | |
| 42 | permissions.allowed.clear(); | |
| 43 | permissions.listeners.clear(); | |
| 44 | renderer = undefined; | |
| 45 | onSave.mockReset(); | |
| 46 | onCancel.mockReset(); | |
| 47 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 48 | }); | |
| 49 | ||
| 50 | afterEach(async () => { | |
| 51 | await act(() => renderer?.unmount()); | |
| 52 | vi.unstubAllGlobals(); | |
| 53 | }); | |
| 54 | ||
| 55 | async function openEditor(options?: { value?: string; allowEmpty?: boolean }) { | |
| 56 | await act(() => { | |
| 57 | renderer = create( | |
| 58 | <PullRequestMarkdownEditor | |
| 59 | value={options?.value ?? "Original description"} | |
| 60 | cwd="/repo" | |
| 61 | environmentId={secondary} | |
| 62 | label="Pull request description" | |
| 63 | saving={false} | |
| 64 | allowEmpty={options?.allowEmpty ?? false} | |
| 65 | onSave={onSave} | |
| 66 | onCancel={onCancel} | |
| 67 | />, | |
| 68 | ); | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | function saveHandler(): () => void { | |
| 73 | return renderer!.root.findByProps({ children: "Save" }).props.onClick; | |
| 74 | } | |
| 75 | ||
| 76 | async function grant(allowed: boolean) { | |
| 77 | await act(() => { | |
| 78 | if (allowed) permissions.allowed.add(secondary); | |
| 79 | else permissions.allowed.delete(secondary); | |
| 80 | permissions.listeners.forEach((listener) => listener()); | |
| 81 | }); | |
| 82 | } | |
| 83 | ||
| 84 | it("does not save a secondary environment's description using the primary grant", async () => { | |
| 85 | permissions.allowed.add(primary); | |
| 86 | await openEditor(); | |
| 87 | ||
| 88 | await act(() => saveHandler()()); | |
| 89 | ||
| 90 | expect(onSave).not.toHaveBeenCalled(); | |
| 91 | }); | |
| 92 | ||
| 93 | it("rejects a retained Save callback when the grant changes before React updates", async () => { | |
| 94 | permissions.allowed.add(secondary); | |
| 95 | await openEditor(); | |
| 96 | const save = saveHandler(); | |
| 97 | permissions.allowed.delete(secondary); | |
| 98 | ||
| 99 | await act(() => save()); | |
| 100 | ||
| 101 | expect(onSave).not.toHaveBeenCalled(); | |
| 102 | }); | |
| 103 | ||
| 104 | it("keeps an edited draft through revocation and saves it after access returns without remounting", async () => { | |
| 105 | permissions.allowed.add(secondary); | |
| 106 | await openEditor(); | |
| 107 | await act(() => { | |
| 108 | renderer!.root.findByType("textarea").props.onChange({ | |
| 109 | target: { value: "Keep these unsaved edits" }, | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | await grant(false); | |
| 114 | await act(() => saveHandler()()); | |
| 115 | expect(onSave).not.toHaveBeenCalled(); | |
| 116 | ||
| 117 | await grant(true); | |
| 118 | await act(() => saveHandler()()); | |
| 119 | expect(onSave).toHaveBeenCalledExactlyOnceWith("Keep these unsaved edits"); | |
| 120 | }); | |
| 121 | ||
| 122 | it("saves with source-control permission alone", async () => { | |
| 123 | permissions.allowed.add(secondary); | |
| 124 | await openEditor(); | |
| 125 | ||
| 126 | await act(() => saveHandler()()); | |
| 127 | ||
| 128 | expect(onSave).toHaveBeenCalledExactlyOnceWith("Original description"); | |
| 129 | }); | |
| 130 | ||
| 131 | it("allows clearing a description only after a pending grant becomes available", async () => { | |
| 132 | await openEditor({ value: "", allowEmpty: true }); | |
| 133 | await act(() => saveHandler()()); | |
| 134 | expect(onSave).not.toHaveBeenCalled(); | |
| 135 | ||
| 136 | await grant(true); | |
| 137 | await act(() => saveHandler()()); | |
| 138 | expect(onSave).toHaveBeenCalledExactlyOnceWith(""); | |
| 139 | }); | |
| 140 | ||
| 141 | it("keeps cancelling a local edit available without a write grant", async () => { | |
| 142 | await openEditor(); | |
| 143 | ||
| 144 | await act(() => renderer!.root.findByProps({ children: "Cancel" }).props.onClick()); | |
| 145 | ||
| 146 | expect(onCancel).toHaveBeenCalledOnce(); | |
| 147 | expect(onSave).not.toHaveBeenCalled(); | |
| 148 | }); |
apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx+28-4| ... | ... | @@ -1,7 +1,12 @@ |
| 1 | 1 | import { useState } from "react"; |
| 2 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | AuthSourceControlWriteScope, | |
| 4 | type EnvironmentId, | |
| 5 | type ScopedThreadRef, | |
| 6 | } from "@t3tools/contracts"; | |
| 3 | 7 | |
| 4 | 8 | import { cn } from "~/lib/utils"; |
| 9 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | |
| 5 | 10 | |
| 6 | 11 | import { Button } from "../ui/button"; |
| 7 | 12 | import { Textarea } from "../ui/textarea"; |
| ... | ... | @@ -43,6 +48,7 @@ export function PullRequestMarkdownEditor({ |
| 43 | 48 | readonly onSave: (next: string) => void; |
| 44 | 49 | readonly onCancel: () => void; |
| 45 | 50 | }) { |
| 51 | const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); | |
| 46 | 52 | const [draft, setDraft] = useState(value); |
| 47 | 53 | const [preview, setPreview] = useState(false); |
| 48 | 54 | // The words this draft started from. React keeps a component instance wherever the same |
| ... | ... | @@ -55,7 +61,7 @@ export function PullRequestMarkdownEditor({ |
| 55 | 61 | setDraft(value); |
| 56 | 62 | } |
| 57 | 63 | const empty = draft.trim().length === 0; |
| 58 | const saveDisabled = saving || (empty && !allowEmpty); | |
| 64 | const saveDisabled = !canWriteSourceControl || saving || (empty && !allowEmpty); | |
| 59 | 65 | |
| 60 | 66 | return ( |
| 61 | 67 | <div |
| ... | ... | @@ -70,7 +76,12 @@ export function PullRequestMarkdownEditor({ |
| 70 | 76 | ) { |
| 71 | 77 | event.preventDefault(); |
| 72 | 78 | event.stopPropagation(); |
| 73 | if (!saveDisabled && !event.repeat) onSave(draft); | |
| 79 | if ( | |
| 80 | !saveDisabled && | |
| 81 | !event.repeat && | |
| 82 | readEnvironmentScope(environmentId, AuthSourceControlWriteScope) | |
| 83 | ) | |
| 84 | onSave(draft); | |
| 74 | 85 | return; |
| 75 | 86 | } |
| 76 | 87 | if (event.key !== "Escape" || saving) return; |
| ... | ... | @@ -119,7 +130,20 @@ export function PullRequestMarkdownEditor({ |
| 119 | 130 | <Button size="xs" variant="ghost" disabled={saving} onClick={onCancel}> |
| 120 | 131 | Cancel |
| 121 | 132 | </Button> |
| 122 | <Button size="xs" variant="outline" disabled={saveDisabled} onClick={() => onSave(draft)}> | |
| 133 | <Button | |
| 134 | size="xs" | |
| 135 | variant="outline" | |
| 136 | disabled={!canWriteSourceControl || saving || (empty && !allowEmpty)} | |
| 137 | onClick={() => { | |
| 138 | if ( | |
| 139 | !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) || | |
| 140 | saving || | |
| 141 | (empty && !allowEmpty) | |
| 142 | ) | |
| 143 | return; | |
| 144 | onSave(draft); | |
| 145 | }} | |
| 146 | > | |
| 123 | 147 | {saving ? "Saving..." : "Save"} |
| 124 | 148 | </Button> |
| 125 | 149 | </div> |
apps/web/src/components/pullRequest/PullRequestReactions.tsx+4-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 1 | 2 | import type { |
| 2 | 3 | EnvironmentId, |
| 3 | 4 | PullRequestReaction, |
| ... | ... | @@ -9,7 +10,6 @@ import { useState } from "react"; |
| 9 | 10 | |
| 10 | 11 | import { cn } from "~/lib/utils"; |
| 11 | 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 12 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 13 | 13 | |
| 14 | 14 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 15 | 15 | import { toastManager } from "../ui/toast"; |
| ... | ... | @@ -58,7 +58,9 @@ export function PullRequestReactionBar({ |
| 58 | 58 | readonly signature: string; |
| 59 | 59 | readonly values: ReadonlyMap<PullRequestReactionContent, boolean>; |
| 60 | 60 | }>({ signature: "", values: EMPTY_PENDING }); |
| 61 | const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, { reportFailure: false }); | |
| 61 | const setReaction = useAtomCommand(pullRequestEnvironment.setReaction, { | |
| 62 | reportFailure: false, | |
| 63 | }); | |
| 62 | 64 | |
| 63 | 65 | const signature = reactionsSignature(reactions); |
| 64 | 66 | const values = pending.signature === signature ? pending.values : EMPTY_PENDING; |
apps/web/src/components/pullRequest/PullRequestReviewForm.tsx+1-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 1 | 2 | /** |
| 2 | 3 | * The review half of the floating composer: the summary and the verdict that sends it, together |
| 3 | 4 | * with whatever line comments the review is holding. The count of those lives on the composer's |
| ... | ... | @@ -9,7 +10,6 @@ import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react"; |
| 9 | 10 | import { useState, type ReactNode, type RefObject } from "react"; |
| 10 | 11 | |
| 11 | 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 12 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 13 | 13 | |
| 14 | 14 | import { Button } from "../ui/button"; |
| 15 | 15 | import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select"; |
apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx+1-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 1 | 2 | /** |
| 2 | 3 | * Asking someone to review, from the row that says who is already reviewing. |
| 3 | 4 | * |
| ... | ... | @@ -15,7 +16,6 @@ import { useMemo, useState } from "react"; |
| 15 | 16 | |
| 16 | 17 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 17 | 18 | import { useEnvironmentQuery } from "~/state/query"; |
| 18 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 19 | 19 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 20 | 20 | |
| 21 | 21 | import { toastManager } from "../ui/toast"; |
apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx+6-1| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 1 | 2 | import type { PullRequestAction } from "@t3tools/contracts"; |
| 3 | import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 2 | 4 | import { useUiStateStore } from "~/uiStateStore"; |
| 3 | 5 | import { Button } from "../ui/button"; |
| 4 | 6 | import { Spinner } from "../ui/spinner"; |
| ... | ... | @@ -37,6 +39,9 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr |
| 37 | 39 | sweeping?: boolean; |
| 38 | 40 | onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void; |
| 39 | 41 | }) { |
| 42 | const canWrite = useAtomValue( | |
| 43 | pullRequestEnvironment.runAction.permissionAtom(entry.environmentId), | |
| 44 | ); | |
| 40 | 45 | const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver( |
| 41 | 46 | entry.environmentId, |
| 42 | 47 | entry.projectId, |
| ... | ... | @@ -90,7 +95,7 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr |
| 90 | 95 | <Button |
| 91 | 96 | variant={action === "close" ? "destructive-outline" : "outline"} |
| 92 | 97 | size="xs" |
| 93 | disabled={busy || (action === "merge" && entry.stack !== undefined)} | |
| 98 | disabled={!canWrite || busy || (action === "merge" && entry.stack !== undefined)} | |
| 94 | 99 | aria-label={`${label} #${entry.number}`} |
| 95 | 100 | onClick={() => void perform(action)} |
| 96 | 101 | onPointerDown={(event) => { |
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+1-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 1 | 2 | import type { |
| 2 | 3 | EnvironmentId, |
| 3 | 4 | PullRequestComment, |
| ... | ... | @@ -16,7 +17,6 @@ import { |
| 16 | 17 | } from "lucide-react"; |
| 17 | 18 | import { useRef, useState, type ReactNode } from "react"; |
| 18 | 19 | |
| 19 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 20 | 20 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 21 | 21 | import { cn } from "~/lib/utils"; |
| 22 | 22 | import { useOpenLink } from "~/browser/useOpenLink"; |
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+1-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 1 | 2 | import type { |
| 2 | 3 | EnvironmentId, |
| 3 | 4 | PullRequestActor, |
| ... | ... | @@ -18,7 +19,6 @@ import { useState, type ReactNode } from "react"; |
| 18 | 19 | import { cn } from "~/lib/utils"; |
| 19 | 20 | import { readLocalApi } from "~/localApi"; |
| 20 | 21 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 21 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 22 | 22 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 23 | 23 | |
| 24 | 24 | import { Button } from "../ui/button"; |
apps/web/src/components/pullRequest/usePullRequestActions.ts+4-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 1 | 2 | /** |
| 2 | 3 | * The actions a pull request offers, extracted from the detail panel so smaller surfaces — the |
| 3 | 4 | * thread details panel's pull request row — perform them through the very same code. Two callers |
| ... | ... | @@ -31,7 +32,6 @@ import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 31 | 32 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 32 | 33 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 33 | 34 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 34 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 35 | 35 | |
| 36 | 36 | import { toastManager } from "../ui/toast"; |
| 37 | 37 | import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic"; |
| ... | ... | @@ -140,7 +140,9 @@ export function usePullRequestActionRunner({ |
| 140 | 140 | /** Small surfaces resolve repository settings on the click, not for every visible row. */ |
| 141 | 141 | resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; |
| 142 | 142 | }) { |
| 143 | const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); | |
| 143 | const runAction = useAtomCommand(pullRequestEnvironment.runAction, { | |
| 144 | reportFailure: false, | |
| 145 | }); | |
| 144 | 146 | const [actionPending, setActionPending] = useState(false); |
| 145 | 147 | const pendingRef = useRef(false); |
| 146 | 148 |
apps/web/src/components/settings/ConnectionsSettings.tsx+6| ... | ... | @@ -31,6 +31,7 @@ import { |
| 31 | 31 | AuthRelayReadScope, |
| 32 | 32 | AuthRelayWriteScope, |
| 33 | 33 | AuthReviewWriteScope, |
| 34 | AuthSourceControlWriteScope, | |
| 34 | 35 | AuthStandardClientScopes, |
| 35 | 36 | AuthTerminalOperateScope, |
| 36 | 37 | type AuthClientSession, |
| ... | ... | @@ -259,6 +260,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ |
| 259 | 260 | title: "Use terminals", |
| 260 | 261 | description: "Create terminals and send input to running shells.", |
| 261 | 262 | }, |
| 263 | { | |
| 264 | scope: AuthSourceControlWriteScope, | |
| 265 | title: "Change source control", | |
| 266 | description: "Commit, push, manage branches and repositories, and change pull requests.", | |
| 267 | }, | |
| 262 | 268 | { |
| 263 | 269 | scope: AuthReviewWriteScope, |
| 264 | 270 | title: "Write reviews", |
apps/web/src/components/threadActionMenu.logic.test.ts+52| ... | ... | @@ -7,6 +7,7 @@ import { |
| 7 | 7 | } from "./threadActionMenu.logic"; |
| 8 | 8 | |
| 9 | 9 | const baseState: ThreadActionMenuState = { |
| 10 | canOperate: true, | |
| 10 | 11 | branch: null, |
| 11 | 12 | projectFilter: null, |
| 12 | 13 | isPinned: false, |
| ... | ... | @@ -39,6 +40,57 @@ function allIds(state: ThreadActionMenuState): string[] { |
| 39 | 40 | } |
| 40 | 41 | |
| 41 | 42 | describe("buildThreadActionMenuItems", () => { |
| 43 | it.each([false, true])( | |
| 44 | "disables both lifecycle directions without permission (reversed: %s)", | |
| 45 | (reversed) => { | |
| 46 | const items = buildThreadActionMenuItems({ | |
| 47 | ...baseState, | |
| 48 | canOperate: false, | |
| 49 | isPinned: reversed, | |
| 50 | isSettled: reversed, | |
| 51 | isSnoozed: reversed, | |
| 52 | }); | |
| 53 | const expected = reversed | |
| 54 | ? [ | |
| 55 | "unpin", | |
| 56 | "unsettle", | |
| 57 | "unsnooze", | |
| 58 | "rename", | |
| 59 | "regenerate-title", | |
| 60 | "auto-settle", | |
| 61 | "archive", | |
| 62 | "delete", | |
| 63 | ] | |
| 64 | : [ | |
| 65 | "pin", | |
| 66 | "settle", | |
| 67 | "snooze", | |
| 68 | "rename", | |
| 69 | "regenerate-title", | |
| 70 | "auto-settle", | |
| 71 | "archive", | |
| 72 | "delete", | |
| 73 | ]; | |
| 74 | expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual(expected); | |
| 75 | expect( | |
| 76 | items.find((item) => item.id === "snooze")?.children?.every((child) => child.disabled) ?? | |
| 77 | true, | |
| 78 | ).toBe(true); | |
| 79 | }, | |
| 80 | ); | |
| 81 | ||
| 82 | it("preserves local actions and restores mutations after a grant", () => { | |
| 83 | const denied = buildThreadActionMenuItems({ ...baseState, canOperate: false, branch: "main" }); | |
| 84 | expect(denied.filter((item) => !item.disabled).map((item) => item.id)).toEqual([ | |
| 85 | "new-thread-on-branch", | |
| 86 | "mark-unread", | |
| 87 | "copy", | |
| 88 | "project-settings", | |
| 89 | ]); | |
| 90 | const allowed = buildThreadActionMenuItems({ ...baseState, canOperate: true }); | |
| 91 | expect(allowed.every((item) => !item.disabled)).toBe(true); | |
| 92 | }); | |
| 93 | ||
| 42 | 94 | it("hides lifecycle items when the environment lacks the capabilities", () => { |
| 43 | 95 | expect( |
| 44 | 96 | ids({ |
apps/web/src/components/threadActionMenu.logic.ts+28-1| ... | ... | @@ -70,6 +70,7 @@ export function buildDraftActionMenuItems(options: { |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | 72 | export interface ThreadActionMenuState { |
| 73 | readonly canOperate: boolean; | |
| 73 | 74 | readonly branch: string | null; |
| 74 | 75 | /** |
| 75 | 76 | * Project scoping for the thread list. Null on surfaces with no scoped |
| ... | ... | @@ -100,6 +101,19 @@ export interface ThreadActionMenuState { |
| 100 | 101 | readonly snoozePresets: ReadonlyArray<SnoozePreset>; |
| 101 | 102 | } |
| 102 | 103 | |
| 104 | /** Local navigation, read markers, and copying remain available to read-only clients. */ | |
| 105 | export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean { | |
| 106 | return ![ | |
| 107 | "new-thread-on-branch", | |
| 108 | "project-settings", | |
| 109 | "mark-unread", | |
| 110 | "copy", | |
| 111 | "copy-path", | |
| 112 | "copy-branch", | |
| 113 | "copy-thread-id", | |
| 114 | ].includes(action); | |
| 115 | } | |
| 116 | ||
| 103 | 117 | /** |
| 104 | 118 | * Single source for the per-thread action menu: the sidebar row's right-click |
| 105 | 119 | * menu and the chat header menu share labels, ordering, and capability gating. |
| ... | ... | @@ -108,7 +122,7 @@ export interface ThreadActionMenuState { |
| 108 | 122 | export function buildThreadActionMenuItems( |
| 109 | 123 | state: ThreadActionMenuState, |
| 110 | 124 | ): ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> { |
| 111 | return [ | |
| 125 | const items: ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> = [ | |
| 112 | 126 | ...(state.branch |
| 113 | 127 | ? [ |
| 114 | 128 | { |
| ... | ... | @@ -235,4 +249,17 @@ export function buildThreadActionMenuItems( |
| 235 | 249 | icon: "trash", |
| 236 | 250 | }, |
| 237 | 251 | ]; |
| 252 | return state.canOperate | |
| 253 | ? items | |
| 254 | : items.map((item) => | |
| 255 | threadActionRequiresOperate(item.id) | |
| 256 | ? { | |
| 257 | ...item, | |
| 258 | disabled: true, | |
| 259 | ...(item.children | |
| 260 | ? { children: item.children.map((child) => ({ ...child, disabled: true })) } | |
| 261 | : {}), | |
| 262 | } | |
| 263 | : item, | |
| 264 | ); | |
| 238 | 265 | } |
apps/web/src/hooks/useThreadActionMenu.test.ts created+221| ... | ... | @@ -0,0 +1,221 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | EnvironmentId, | |
| 4 | ThreadId, | |
| 5 | type ContextMenuItem, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import { AsyncResult } from "effect/reactivity"; | |
| 8 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 9 | ||
| 10 | import type { ThreadActionMenuId } from "../components/threadActionMenu.logic"; | |
| 11 | ||
| 12 | function deferred<T>() { | |
| 13 | let resolve!: (value: T) => void; | |
| 14 | const promise = new Promise<T>((complete) => { | |
| 15 | resolve = complete; | |
| 16 | }); | |
| 17 | return { promise, resolve }; | |
| 18 | } | |
| 19 | ||
| 20 | const state = vi.hoisted(() => ({ | |
| 21 | granted: new Set<string>(), | |
| 22 | effects: [] as string[], | |
| 23 | completed: deferred<void>(), | |
| 24 | show: vi.fn< | |
| 25 | ( | |
| 26 | items: ReadonlyArray<ContextMenuItem<ThreadActionMenuId>>, | |
| 27 | position: { x: number; y: number }, | |
| 28 | ) => Promise<ThreadActionMenuId | null> | |
| 29 | >(), | |
| 30 | })); | |
| 31 | ||
| 32 | function recordEffect(action: string) { | |
| 33 | state.effects.push(action); | |
| 34 | state.completed.resolve(); | |
| 35 | } | |
| 36 | ||
| 37 | vi.mock("../components/CustomSnoozeDialog", () => ({ requestCustomSnooze: vi.fn() })); | |
| 38 | vi.mock("react", () => ({ | |
| 39 | useCallback: (callback: unknown) => callback, | |
| 40 | useMemo: (factory: () => unknown) => factory(), | |
| 41 | })); | |
| 42 | vi.mock("@tanstack/react-router", () => ({ | |
| 43 | useRouter: () => ({ navigate: async () => recordEffect("project-settings") }), | |
| 44 | })); | |
| 45 | vi.mock("../state/session", () => ({ | |
| 46 | readEnvironmentScope: (environmentId: string, scope: string) => | |
| 47 | scope === AuthOrchestrationOperateScope && state.granted.has(environmentId), | |
| 48 | })); | |
| 49 | vi.mock("../state/entities", () => ({ | |
| 50 | readEnvironmentSupportsAutoSettleOptOut: () => true, | |
| 51 | readEnvironmentSupportsPinning: () => true, | |
| 52 | readEnvironmentSupportsSettlement: () => true, | |
| 53 | readEnvironmentSupportsSnooze: () => true, | |
| 54 | readEnvironmentSupportsTitleRegeneration: () => true, | |
| 55 | readThreadShell: () => ({ | |
| 56 | id: "thread", | |
| 57 | environmentId: "secondary", | |
| 58 | projectId: "project", | |
| 59 | title: "Thread", | |
| 60 | branch: "main", | |
| 61 | worktreePath: null, | |
| 62 | runtime: null, | |
| 63 | latestRun: null, | |
| 64 | }), | |
| 65 | useProjects: () => [{ id: "project", environmentId: "secondary" }], | |
| 66 | })); | |
| 67 | vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "primary" })); | |
| 68 | vi.mock("../state/threads", () => ({ threadEnvironment: { updateMetadata: "metadata" } })); | |
| 69 | vi.mock("../state/use-atom-command", () => ({ | |
| 70 | useAtomCommand: () => async () => { | |
| 71 | recordEffect("metadata"); | |
| 72 | return AsyncResult.success(undefined); | |
| 73 | }, | |
| 74 | })); | |
| 75 | vi.mock("../localApi", () => ({ | |
| 76 | readLocalApi: () => ({ | |
| 77 | contextMenu: { show: state.show, close: () => {} }, | |
| 78 | dialogs: { | |
| 79 | confirm: async () => { | |
| 80 | recordEffect("confirm"); | |
| 81 | return false; | |
| 82 | }, | |
| 83 | }, | |
| 84 | }), | |
| 85 | })); | |
| 86 | vi.mock("../logicalProject", () => ({ | |
| 87 | deriveLogicalProjectKeyFromSettings: () => "project", | |
| 88 | derivePhysicalProjectKey: () => "project", | |
| 89 | selectProjectGroupingSettings: () => ({}), | |
| 90 | })); | |
| 91 | vi.mock("../sidebarProjectGrouping", () => ({ | |
| 92 | buildPhysicalToLogicalProjectKeyMap: () => new Map(), | |
| 93 | })); | |
| 94 | vi.mock("../uiStateStore", () => ({ | |
| 95 | useUiStateStore: (select: (store: unknown) => unknown) => | |
| 96 | select({ | |
| 97 | markThreadUnread: () => recordEffect("mark-unread"), | |
| 98 | }), | |
| 99 | })); | |
| 100 | vi.mock("../components/ui/toast", () => ({ | |
| 101 | stackedThreadToast: (toast: unknown) => toast, | |
| 102 | toastManager: { add: () => state.completed.resolve() }, | |
| 103 | })); | |
| 104 | vi.mock("../components/Sidebar.snooze", () => ({ | |
| 105 | resolveSnoozePresets: () => [ | |
| 106 | { id: "hour", label: "In 1 hour", whenLabel: "3 PM", snoozedUntil: "2099-01-01T00:00:00Z" }, | |
| 107 | ], | |
| 108 | snoozeWakeDescription: () => "later", | |
| 109 | })); | |
| 110 | vi.mock("./useCopyToClipboard", () => ({ | |
| 111 | useCopyToClipboard: () => ({ copyToClipboard: () => recordEffect("copy") }), | |
| 112 | })); | |
| 113 | vi.mock("./useHandleNewThread", () => ({ | |
| 114 | useNewThreadHandler: () => async () => recordEffect("draft"), | |
| 115 | })); | |
| 116 | vi.mock("./useSettings", () => ({ | |
| 117 | useClientSettings: (select: (settings: unknown) => unknown) => | |
| 118 | select({ | |
| 119 | confirmThreadDelete: true, | |
| 120 | confirmThreadArchive: true, | |
| 121 | timestampFormat: "12-hour", | |
| 122 | }), | |
| 123 | })); | |
| 124 | vi.mock("./useThreadActions", () => ({ | |
| 125 | useThreadActions: () => | |
| 126 | Object.fromEntries( | |
| 127 | [ | |
| 128 | "markThreadUnread", | |
| 129 | "settleThread", | |
| 130 | "unsettleThread", | |
| 131 | "snoozeThread", | |
| 132 | "unsnoozeThread", | |
| 133 | "pinThread", | |
| 134 | "confirmAndUnpinThread", | |
| 135 | "archiveThread", | |
| 136 | "deleteThread", | |
| 137 | ].map((action) => [ | |
| 138 | action, | |
| 139 | async () => { | |
| 140 | recordEffect(action === "markThreadUnread" ? "mark-unread" : action); | |
| 141 | return AsyncResult.success(undefined); | |
| 142 | }, | |
| 143 | ]), | |
| 144 | ), | |
| 145 | })); | |
| 146 | ||
| 147 | import { useThreadActionMenu } from "./useThreadActionMenu"; | |
| 148 | ||
| 149 | const target = { | |
| 150 | environmentId: EnvironmentId.make("secondary"), | |
| 151 | threadId: ThreadId.make("thread"), | |
| 152 | }; | |
| 153 | const position = { x: 10, y: 20 }; | |
| 154 | const createMenu = () => | |
| 155 | useThreadActionMenu({ | |
| 156 | threadRef: target, | |
| 157 | projectCwd: "/project", | |
| 158 | onStartRename: () => recordEffect("rename"), | |
| 159 | }); | |
| 160 | ||
| 161 | beforeEach(() => { | |
| 162 | state.granted = new Set(["primary"]); | |
| 163 | state.effects = []; | |
| 164 | state.completed = deferred<void>(); | |
| 165 | state.show.mockReset().mockResolvedValue(null); | |
| 166 | }); | |
| 167 | ||
| 168 | describe("thread menu permissions", () => { | |
| 169 | it("disables mutations for a denied secondary environment", () => { | |
| 170 | createMenu().openMenu(position); | |
| 171 | const items = state.show.mock.calls[0]![0]; | |
| 172 | expect(items.find((item) => item.id === "rename")?.disabled).toBe(true); | |
| 173 | expect(items.find((item) => item.id === "delete")?.disabled).toBe(true); | |
| 174 | expect(items.find((item) => item.id === "copy")?.disabled).not.toBe(true); | |
| 175 | }); | |
| 176 | ||
| 177 | it("allows the target grant even when the primary environment is denied", () => { | |
| 178 | state.granted = new Set(["secondary"]); | |
| 179 | createMenu().openMenu(position); | |
| 180 | expect(state.show.mock.calls[0]![0].find((item) => item.id === "rename")?.disabled).not.toBe( | |
| 181 | true, | |
| 182 | ); | |
| 183 | }); | |
| 184 | ||
| 185 | it("refreshes availability when a retained menu opener gains permission", () => { | |
| 186 | const menu = createMenu(); | |
| 187 | menu.openMenu(position); | |
| 188 | expect(state.show.mock.calls[0]![0].find((item) => item.id === "rename")?.disabled).toBe(true); | |
| 189 | state.granted.add("secondary"); | |
| 190 | menu.openMenu(position); | |
| 191 | expect(state.show.mock.calls[1]![0].find((item) => item.id === "rename")?.disabled).not.toBe( | |
| 192 | true, | |
| 193 | ); | |
| 194 | }); | |
| 195 | ||
| 196 | it.each(["rename", "regenerate-title", "delete", "pin", "settle", "archive"] as const)( | |
| 197 | "%s rechecks after the native menu closes", | |
| 198 | async (action) => { | |
| 199 | state.granted.add("secondary"); | |
| 200 | const choice = deferred<ThreadActionMenuId | null>(); | |
| 201 | state.show.mockReturnValue(choice.promise); | |
| 202 | createMenu().openMenu(position); | |
| 203 | state.granted.delete("secondary"); | |
| 204 | choice.resolve(action); | |
| 205 | await state.completed.promise; | |
| 206 | expect(state.effects).toEqual([]); | |
| 207 | }, | |
| 208 | ); | |
| 209 | ||
| 210 | it.each([ | |
| 211 | ["new-thread-on-branch", "draft"], | |
| 212 | ["copy-thread-id", "copy"], | |
| 213 | ["mark-unread", "mark-unread"], | |
| 214 | ["project-settings", "project-settings"], | |
| 215 | ] as const)("keeps %s available without task permission", async (action, effect) => { | |
| 216 | state.show.mockResolvedValue(action); | |
| 217 | createMenu().openMenu(position); | |
| 218 | await state.completed.promise; | |
| 219 | expect(state.effects).toEqual([effect]); | |
| 220 | }); | |
| 221 | }); |
apps/web/src/hooks/useThreadActionMenu.ts+20-3| ... | ... | @@ -7,18 +7,24 @@ import { |
| 7 | 7 | squashAtomCommandFailure, |
| 8 | 8 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | 9 | import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; |
| 10 | import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | |
| 10 | import { | |
| 11 | AuthOrchestrationOperateScope, | |
| 12 | type ScopedThreadRef, | |
| 13 | type ThreadId, | |
| 14 | } from "@t3tools/contracts"; | |
| 11 | 15 | import { useRouter } from "@tanstack/react-router"; |
| 12 | 16 | import { useCallback, useMemo } from "react"; |
| 13 | 17 | |
| 14 | 18 | import { resolveSnoozePresets } from "../components/Sidebar.snooze"; |
| 15 | 19 | import { |
| 16 | 20 | buildThreadActionMenuItems, |
| 21 | threadActionRequiresOperate, | |
| 17 | 22 | type ThreadActionMenuId, |
| 18 | 23 | } from "../components/threadActionMenu.logic"; |
| 19 | 24 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 20 | 25 | import { threadEnvironment } from "../state/threads"; |
| 21 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 26 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; | |
| 27 | import { readEnvironmentScope } from "../state/session"; | |
| 22 | 28 | import { |
| 23 | 29 | readEnvironmentSupportsAutoSettleOptOut, |
| 24 | 30 | readEnvironmentSupportsPinning, |
| ... | ... | @@ -94,7 +100,7 @@ export function useThreadActionMenu(input: { |
| 94 | 100 | deleteThread, |
| 95 | 101 | markThreadUnread, |
| 96 | 102 | } = useThreadActions(); |
| 97 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | |
| 103 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { | |
| 98 | 104 | reportFailure: false, |
| 99 | 105 | }); |
| 100 | 106 | const handleNewThread = useNewThreadHandler(); |
| ... | ... | @@ -142,6 +148,7 @@ export function useThreadActionMenu(input: { |
| 142 | 148 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 143 | 149 | const snoozePresets = resolveSnoozePresets(now, timestampFormat); |
| 144 | 150 | const items = buildThreadActionMenuItems({ |
| 151 | canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), | |
| 145 | 152 | branch: thread.branch ?? null, |
| 146 | 153 | projectFilter: null, |
| 147 | 154 | isPinned: thread.pinnedAt != null, |
| ... | ... | @@ -157,6 +164,16 @@ export function useThreadActionMenu(input: { |
| 157 | 164 | const clicked = await settlePromise(() => api.contextMenu.show(items, position)); |
| 158 | 165 | if (clicked._tag === "Failure" || clicked.value === null) return; |
| 159 | 166 | const action: ThreadActionMenuId = clicked.value; |
| 167 | if ( | |
| 168 | threadActionRequiresOperate(action) && | |
| 169 | !readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) | |
| 170 | ) { | |
| 171 | failureToast( | |
| 172 | "Thread action unavailable", | |
| 173 | new Error("This connection cannot change threads."), | |
| 174 | ); | |
| 175 | return; | |
| 176 | } | |
| 160 | 177 | if (action.startsWith("snooze:")) { |
| 161 | 178 | const preset = |
| 162 | 179 | action === "snooze:custom" |
apps/web/src/hooks/useThreadActions.permissions.test.ts created+354| ... | ... | @@ -0,0 +1,354 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | AuthSourceControlWriteScope, | |
| 4 | EnvironmentAuthorizationError, | |
| 5 | EnvironmentId, | |
| 6 | ProjectId, | |
| 7 | ThreadId, | |
| 8 | type ScopedThreadRef, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import * as Cause from "effect/Cause"; | |
| 11 | import { AsyncResult } from "effect/reactivity"; | |
| 12 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 13 | ||
| 14 | function deferred<T>() { | |
| 15 | let resolve!: (value: T) => void; | |
| 16 | const promise = new Promise<T>((complete) => { | |
| 17 | resolve = complete; | |
| 18 | }); | |
| 19 | return { promise, resolve }; | |
| 20 | } | |
| 21 | ||
| 22 | const state = vi.hoisted(() => ({ | |
| 23 | scopes: new Map<string, Set<string>>(), | |
| 24 | threads: [] as { | |
| 25 | environmentId: EnvironmentId; | |
| 26 | id: ThreadId; | |
| 27 | projectId: ProjectId; | |
| 28 | title: string; | |
| 29 | worktreePath: string | null; | |
| 30 | session: { status: "ready" | "stopped" } | null; | |
| 31 | latestTurn: null; | |
| 32 | }[], | |
| 33 | requests: [] as { action: string; environmentId: string; input: { threadId?: string } }[], | |
| 34 | localEffects: [] as string[], | |
| 35 | confirm: vi.fn<(message: string) => Promise<boolean>>(), | |
| 36 | toasts: [] as string[], | |
| 37 | afterRequest: undefined as ((action: string) => void) | undefined, | |
| 38 | sessionLookupFails: false, | |
| 39 | })); | |
| 40 | ||
| 41 | vi.mock("react", () => ({ | |
| 42 | useCallback: (callback: unknown) => callback, | |
| 43 | useMemo: (factory: () => unknown) => factory(), | |
| 44 | useRef: (current: unknown) => ({ current }), | |
| 45 | })); | |
| 46 | vi.mock("@tanstack/react-router", () => ({ | |
| 47 | useRouter: () => ({ state: { matches: [] }, navigate: async () => {} }), | |
| 48 | })); | |
| 49 | vi.mock("../state/session", () => ({ | |
| 50 | environmentSession: { sessionStateAtom: "session" }, | |
| 51 | readEnvironmentScope: (environmentId: string, scope: string) => | |
| 52 | state.scopes.get(environmentId)?.has(scope) === true, | |
| 53 | })); | |
| 54 | vi.mock("../state/use-atom-command", () => ({ | |
| 55 | useAtomCommand: | |
| 56 | (action: string) => | |
| 57 | async (request: { environmentId: string; input: { threadId?: string } }) => { | |
| 58 | state.requests.push({ action, ...request }); | |
| 59 | const scope = | |
| 60 | action === "removeWorktree" ? AuthSourceControlWriteScope : AuthOrchestrationOperateScope; | |
| 61 | if (!state.scopes.get(request.environmentId)?.has(scope)) { | |
| 62 | return AsyncResult.failure(Cause.fail(new Error("Server denied the request"))); | |
| 63 | } | |
| 64 | state.afterRequest?.(action); | |
| 65 | return AsyncResult.success(undefined); | |
| 66 | }, | |
| 67 | })); | |
| 68 | vi.mock("../state/use-atom-query-runner", () => ({ | |
| 69 | useAtomQueryRunner: () => async (environmentId: string) => | |
| 70 | state.sessionLookupFails | |
| 71 | ? AsyncResult.failure(Cause.fail(new Error("Session lookup failed"))) | |
| 72 | : AsyncResult.success({ | |
| 73 | authenticated: true, | |
| 74 | scopes: [...(state.scopes.get(environmentId) ?? [])], | |
| 75 | }), | |
| 76 | })); | |
| 77 | vi.mock("../state/threads", () => ({ | |
| 78 | threadEnvironment: Object.fromEntries( | |
| 79 | [ | |
| 80 | "archive", | |
| 81 | "unarchive", | |
| 82 | "delete", | |
| 83 | "settle", | |
| 84 | "unsettle", | |
| 85 | "pin", | |
| 86 | "unpin", | |
| 87 | "reorderPin", | |
| 88 | "snooze", | |
| 89 | "unsnooze", | |
| 90 | "stopSession", | |
| 91 | ].map((action) => [action, action]), | |
| 92 | ), | |
| 93 | })); | |
| 94 | vi.mock("../state/vcs", () => ({ | |
| 95 | vcsEnvironment: { removeWorktree: "removeWorktree", refreshStatus: "refreshStatus" }, | |
| 96 | })); | |
| 97 | vi.mock("../state/entities", () => ({ | |
| 98 | readEnvironmentSupportsPinning: () => true, | |
| 99 | readEnvironmentSupportsPinReorder: () => true, | |
| 100 | readEnvironmentSupportsSettlement: () => true, | |
| 101 | readEnvironmentSupportsSnooze: () => true, | |
| 102 | readThreadShell: (ref: ScopedThreadRef) => | |
| 103 | state.threads.find( | |
| 104 | (thread) => thread.environmentId === ref.environmentId && thread.id === ref.threadId, | |
| 105 | ) ?? null, | |
| 106 | readThreadShells: () => state.threads, | |
| 107 | readEnvironmentThreadRefs: (environmentId: EnvironmentId) => | |
| 108 | state.threads | |
| 109 | .filter((thread) => thread.environmentId === environmentId) | |
| 110 | .map((thread) => ({ environmentId, threadId: thread.id })), | |
| 111 | readProject: () => ({ workspaceRoot: "/project" }), | |
| 112 | })); | |
| 113 | vi.mock("../components/Sidebar.logic", () => ({ | |
| 114 | getFallbackThreadIdAfterDelete: () => null, | |
| 115 | pinOrderKeyBetween: () => "a", | |
| 116 | })); | |
| 117 | vi.mock("../composerDraftStore", () => ({ | |
| 118 | useComposerDraftStore: (select: (store: unknown) => unknown) => | |
| 119 | select({ | |
| 120 | clearDraftThread: () => state.localEffects.push("clear-draft"), | |
| 121 | clearProjectDraftThreadById: () => state.localEffects.push("clear-project-draft"), | |
| 122 | }), | |
| 123 | })); | |
| 124 | vi.mock("../terminalUiStateStore", () => ({ | |
| 125 | useTerminalUiStateStore: (select: (store: unknown) => unknown) => | |
| 126 | select({ | |
| 127 | clearTerminalUiState: () => state.localEffects.push("clear-terminal-ui"), | |
| 128 | }), | |
| 129 | })); | |
| 130 | vi.mock("../uiStateStore", () => ({ | |
| 131 | useUiStateStore: (select: (store: unknown) => unknown) => | |
| 132 | select({ | |
| 133 | markThreadVisited: () => state.localEffects.push("mark-visited"), | |
| 134 | }), | |
| 135 | })); | |
| 136 | vi.mock("../lib/archivedThreadsState", () => ({ | |
| 137 | refreshArchivedThreadsForEnvironment: () => state.localEffects.push("refresh-archive"), | |
| 138 | })); | |
| 139 | vi.mock("../lib/composerDraftUploads", () => ({ | |
| 140 | releaseComposerDraftUploads: () => state.localEffects.push("release-uploads"), | |
| 141 | })); | |
| 142 | vi.mock("../localApi", () => ({ | |
| 143 | readLocalApi: () => ({ dialogs: { confirm: state.confirm } }), | |
| 144 | })); | |
| 145 | vi.mock("../threadRoutes", () => ({ | |
| 146 | resolveThreadRouteRef: () => null, | |
| 147 | buildThreadRouteParams: (ref: ScopedThreadRef) => ref, | |
| 148 | })); | |
| 149 | vi.mock("../components/ui/toast", () => ({ | |
| 150 | stackedThreadToast: (toast: unknown) => toast, | |
| 151 | toastManager: { | |
| 152 | add: (toast: { title?: string }) => { | |
| 153 | if (toast.title) state.toasts.push(toast.title); | |
| 154 | }, | |
| 155 | }, | |
| 156 | })); | |
| 157 | vi.mock("./useHandleNewThread", () => ({ useNewThreadHandler: () => async () => {} })); | |
| 158 | vi.mock("./useSettings", () => ({ | |
| 159 | useClientSettings: (select: (settings: unknown) => unknown) => | |
| 160 | select({ | |
| 161 | sidebarThreadSortOrder: "createdAt", | |
| 162 | confirmThreadDelete: true, | |
| 163 | confirmThreadUnpin: true, | |
| 164 | }), | |
| 165 | })); | |
| 166 | ||
| 167 | import { useThreadActions } from "./useThreadActions"; | |
| 168 | ||
| 169 | const primary = EnvironmentId.make("primary"); | |
| 170 | const secondary = EnvironmentId.make("secondary"); | |
| 171 | const target = { environmentId: secondary, threadId: ThreadId.make("thread") }; | |
| 172 | type ThreadActions = ReturnType<typeof useThreadActions>; | |
| 173 | const operations = [ | |
| 174 | { | |
| 175 | name: "archive", | |
| 176 | run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.archiveThread(ref), | |
| 177 | }, | |
| 178 | { | |
| 179 | name: "unarchive", | |
| 180 | run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unarchiveThread(ref), | |
| 181 | }, | |
| 182 | { | |
| 183 | name: "settle", | |
| 184 | run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.settleThread(ref), | |
| 185 | }, | |
| 186 | { | |
| 187 | name: "unsettle", | |
| 188 | run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsettleThread(ref), | |
| 189 | }, | |
| 190 | { | |
| 191 | name: "snooze", | |
| 192 | run: (actions: ThreadActions, ref: ScopedThreadRef) => | |
| 193 | actions.snoozeThread(ref, "2099-01-01T00:00:00Z"), | |
| 194 | }, | |
| 195 | { | |
| 196 | name: "unsnooze", | |
| 197 | run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unsnoozeThread(ref), | |
| 198 | }, | |
| 199 | { name: "pin", run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.pinThread(ref) }, | |
| 200 | { | |
| 201 | name: "unpin", | |
| 202 | run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.unpinThread(ref), | |
| 203 | }, | |
| 204 | { | |
| 205 | name: "reorderPin", | |
| 206 | run: (actions: ThreadActions, ref: ScopedThreadRef) => actions.reorderPinnedThread(ref, "b"), | |
| 207 | }, | |
| 208 | ] as const; | |
| 209 | ||
| 210 | beforeEach(() => { | |
| 211 | state.scopes = new Map([ | |
| 212 | [primary, new Set([AuthOrchestrationOperateScope])], | |
| 213 | [secondary, new Set<string>()], | |
| 214 | ]); | |
| 215 | state.threads = [ | |
| 216 | { | |
| 217 | environmentId: secondary, | |
| 218 | id: target.threadId, | |
| 219 | projectId: ProjectId.make("project"), | |
| 220 | title: "Thread", | |
| 221 | worktreePath: null, | |
| 222 | session: null, | |
| 223 | latestTurn: null, | |
| 224 | }, | |
| 225 | ]; | |
| 226 | state.requests = []; | |
| 227 | state.localEffects = []; | |
| 228 | state.confirm.mockReset().mockResolvedValue(true); | |
| 229 | state.toasts = []; | |
| 230 | state.afterRequest = undefined; | |
| 231 | state.sessionLookupFails = false; | |
| 232 | }); | |
| 233 | ||
| 234 | describe("thread action permissions", () => { | |
| 235 | it.each(operations)("$name requires the target environment's grant", async ({ run }) => { | |
| 236 | const result = await run(useThreadActions(), target); | |
| 237 | expect(result._tag).toBe("Failure"); | |
| 238 | expect(state.requests).toEqual([]); | |
| 239 | expect(state.localEffects).toEqual([]); | |
| 240 | }); | |
| 241 | ||
| 242 | it.each(operations)("$name rechecks a retained callback after revocation", async ({ run }) => { | |
| 243 | state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | |
| 244 | const actions = useThreadActions(); | |
| 245 | state.scopes.get(secondary)!.clear(); | |
| 246 | await run(actions, target); | |
| 247 | expect(state.requests).toEqual([]); | |
| 248 | }); | |
| 249 | ||
| 250 | it.each(operations)( | |
| 251 | "$name works after the target gains only task permission", | |
| 252 | async ({ name, run }) => { | |
| 253 | const actions = useThreadActions(); | |
| 254 | state.scopes.get(primary)!.clear(); | |
| 255 | state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | |
| 256 | expect((await run(actions, target))._tag).toBe("Success"); | |
| 257 | expect(state.requests).toEqual([ | |
| 258 | expect.objectContaining({ action: name, environmentId: secondary }), | |
| 259 | ]); | |
| 260 | }, | |
| 261 | ); | |
| 262 | ||
| 263 | it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)( | |
| 264 | "%s blocks a forbidden confirmation", | |
| 265 | async (action) => { | |
| 266 | await useThreadActions()[action](target); | |
| 267 | expect(state.confirm).not.toHaveBeenCalled(); | |
| 268 | expect(state.requests).toEqual([]); | |
| 269 | }, | |
| 270 | ); | |
| 271 | ||
| 272 | it.each(["confirmAndDeleteThread", "confirmAndUnpinThread"] as const)( | |
| 273 | "%s rechecks after the confirmation", | |
| 274 | async (action) => { | |
| 275 | state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | |
| 276 | const confirmation = deferred<boolean>(); | |
| 277 | state.confirm.mockReturnValue(confirmation.promise); | |
| 278 | const result = useThreadActions()[action](target); | |
| 279 | expect(state.confirm).toHaveBeenCalledOnce(); | |
| 280 | state.scopes.get(secondary)!.clear(); | |
| 281 | confirmation.resolve(true); | |
| 282 | await result; | |
| 283 | expect(state.requests).toEqual([]); | |
| 284 | expect(state.localEffects).toEqual([]); | |
| 285 | }, | |
| 286 | ); | |
| 287 | ||
| 288 | it("identifies the missing task scope when thread deletion is denied", async () => { | |
| 289 | const result = await useThreadActions().deleteThread(target); | |
| 290 | ||
| 291 | expect(result._tag).toBe("Failure"); | |
| 292 | if (result._tag !== "Failure") throw new Error("Expected permission denial"); | |
| 293 | const error = Cause.squash<unknown>(result.cause); | |
| 294 | expect(error).toBeInstanceOf(EnvironmentAuthorizationError); | |
| 295 | expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); | |
| 296 | expect(state.requests).toEqual([]); | |
| 297 | expect(state.localEffects).toEqual([]); | |
| 298 | }); | |
| 299 | ||
| 300 | it("deletes an archived thread only with its own environment's grant", async () => { | |
| 301 | state.threads = []; | |
| 302 | const actions = useThreadActions(); | |
| 303 | await actions.deleteThread(target); | |
| 304 | expect(state.requests).toEqual([]); | |
| 305 | state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | |
| 306 | expect((await actions.deleteThread(target))._tag).toBe("Success"); | |
| 307 | expect(state.requests).toEqual([ | |
| 308 | expect.objectContaining({ action: "delete", environmentId: secondary }), | |
| 309 | ]); | |
| 310 | }); | |
| 311 | ||
| 312 | it("stops before delete and local cleanup when permission is revoked during session stop", async () => { | |
| 313 | state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | |
| 314 | state.threads[0]!.session = { status: "ready" }; | |
| 315 | state.afterRequest = () => state.scopes.get(secondary)!.clear(); | |
| 316 | expect((await useThreadActions().deleteThread(target))._tag).toBe("Failure"); | |
| 317 | expect(state.requests.map((request) => request.action)).toEqual(["stopSession"]); | |
| 318 | expect(state.localEffects).toEqual([]); | |
| 319 | }); | |
| 320 | ||
| 321 | it.each([ | |
| 322 | { reason: "without source-control permission", sessionLookupFails: false }, | |
| 323 | { reason: "when the permission lookup fails", sessionLookupFails: true }, | |
| 324 | ])("deletes a worktree thread and keeps its worktree $reason", async ({ sessionLookupFails }) => { | |
| 325 | state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | |
| 326 | state.threads[0]!.worktreePath = "/worktrees/thread"; | |
| 327 | state.sessionLookupFails = sessionLookupFails; | |
| 328 | expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); | |
| 329 | expect(state.confirm).not.toHaveBeenCalled(); | |
| 330 | expect(state.requests.map((request) => request.action)).toEqual(["stopSession", "delete"]); | |
| 331 | expect(state.localEffects).toContain("clear-terminal-ui"); | |
| 332 | }); | |
| 333 | ||
| 334 | it("does not request worktree removal after its grant is revoked during delete", async () => { | |
| 335 | state.scopes | |
| 336 | .get(secondary)! | |
| 337 | .add(AuthOrchestrationOperateScope) | |
| 338 | .add(AuthSourceControlWriteScope); | |
| 339 | state.threads[0]!.worktreePath = "/worktrees/thread"; | |
| 340 | state.afterRequest = () => state.scopes.get(secondary)!.delete(AuthSourceControlWriteScope); | |
| 341 | const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); | |
| 342 | try { | |
| 343 | // The thread is already gone, so cleanup reports itself in a toast | |
| 344 | // instead of failing the deletion. | |
| 345 | const result = await useThreadActions().deleteThread(target); | |
| 346 | expect(result._tag).toBe("Success"); | |
| 347 | expect(state.requests.map((request) => request.action)).toEqual(["stopSession", "delete"]); | |
| 348 | expect(state.toasts).toContain("Failed to delete worktree"); | |
| 349 | expect(state.localEffects).toContain("clear-terminal-ui"); | |
| 350 | } finally { | |
| 351 | consoleError.mockRestore(); | |
| 352 | } | |
| 353 | }); | |
| 354 | }); |
apps/web/src/hooks/useThreadActions.ts+81-34| ... | ... | @@ -7,7 +7,14 @@ import { |
| 7 | 7 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 8 | 8 | import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| 9 | 9 | import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; |
| 10 | import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | |
| 10 | import { | |
| 11 | AuthOrchestrationOperateScope, | |
| 12 | AuthSourceControlWriteScope, | |
| 13 | EnvironmentAuthorizationError, | |
| 14 | EnvironmentId, | |
| 15 | type ScopedThreadRef, | |
| 16 | ThreadId, | |
| 17 | } from "@t3tools/contracts"; | |
| 11 | 18 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 12 | 19 | import * as Cause from "effect/Cause"; |
| 13 | 20 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -17,6 +24,7 @@ import { useCallback, useMemo, useRef } from "react"; |
| 17 | 24 | |
| 18 | 25 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; |
| 19 | 26 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 27 | import { environmentSession, readEnvironmentScope } from "../state/session"; | |
| 20 | 28 | import { terminalEnvironment } from "../state/terminal"; |
| 21 | 29 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 22 | 30 | import { environmentServerConfigsAtom } from "../state/server"; |
| ... | ... | @@ -49,6 +57,8 @@ import { useClientSettings } from "./useSettings"; |
| 49 | 57 | import * as ThreadUndo from "./threadUndo"; |
| 50 | 58 | import { showThreadUndoNotice } from "./showThreadUndoNotice"; |
| 51 | 59 | import { useAtomCommand } from "../state/use-atom-command"; |
| 60 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; | |
| 61 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | |
| 52 | 62 | |
| 53 | 63 | export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()( |
| 54 | 64 | "ThreadArchiveBlockedError", |
| ... | ... | @@ -243,49 +253,64 @@ export function useAcknowledgeThreadWoke() { |
| 243 | 253 | ); |
| 244 | 254 | } |
| 245 | 255 | |
| 256 | function threadOperationFailure(target: ScopedThreadRef) { | |
| 257 | return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) | |
| 258 | ? null | |
| 259 | : AsyncResult.failure( | |
| 260 | Cause.fail( | |
| 261 | new EnvironmentAuthorizationError({ | |
| 262 | message: "This connection cannot change threads.", | |
| 263 | requiredScope: AuthOrchestrationOperateScope, | |
| 264 | }), | |
| 265 | ), | |
| 266 | ); | |
| 267 | } | |
| 268 | ||
| 246 | 269 | export function useThreadActions() { |
| 247 | const closeTerminal = useAtomCommand(terminalEnvironment.close); | |
| 248 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { | |
| 270 | const archiveThreadMutation = useOrchestrationCommand(threadEnvironment.archive, { | |
| 249 | 271 | reportFailure: false, |
| 250 | 272 | }); |
| 251 | const unarchiveThreadMutation = useAtomCommand(threadEnvironment.unarchive, { | |
| 273 | const unarchiveThreadMutation = useOrchestrationCommand(threadEnvironment.unarchive, { | |
| 252 | 274 | reportFailure: false, |
| 253 | 275 | }); |
| 254 | const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { | |
| 276 | const deleteThreadMutation = useOrchestrationCommand(threadEnvironment.delete, { | |
| 255 | 277 | reportFailure: false, |
| 256 | 278 | }); |
| 257 | const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { | |
| 279 | const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, { | |
| 258 | 280 | reportFailure: false, |
| 259 | 281 | }); |
| 260 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { | |
| 282 | const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { | |
| 261 | 283 | reportFailure: false, |
| 262 | 284 | }); |
| 263 | const pinThreadMutation = useAtomCommand(threadEnvironment.pin, { | |
| 285 | const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, { | |
| 264 | 286 | reportFailure: false, |
| 265 | 287 | }); |
| 266 | const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, { | |
| 288 | const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, { | |
| 267 | 289 | reportFailure: false, |
| 268 | 290 | }); |
| 269 | const setThreadAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, { | |
| 291 | const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, { | |
| 270 | 292 | reportFailure: false, |
| 271 | 293 | }); |
| 272 | const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { | |
| 294 | const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, { | |
| 273 | 295 | reportFailure: false, |
| 274 | 296 | }); |
| 275 | const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, { | |
| 297 | const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, { | |
| 276 | 298 | reportFailure: false, |
| 277 | 299 | }); |
| 278 | const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { | |
| 300 | const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, { | |
| 279 | 301 | reportFailure: false, |
| 280 | 302 | }); |
| 281 | const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { | |
| 303 | const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { | |
| 282 | 304 | reportFailure: false, |
| 283 | 305 | }); |
| 284 | 306 | const markThreadUnread = useMarkThreadUnread(); |
| 285 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); | |
| 307 | const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession); | |
| 286 | 308 | const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { |
| 287 | 309 | reportFailure: false, |
| 288 | 310 | }); |
| 311 | const loadSessionState = useAtomQueryRunner(environmentSession.sessionStateAtom, { | |
| 312 | reportFailure: false, | |
| 313 | }); | |
| 289 | 314 | const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { |
| 290 | 315 | reportFailure: false, |
| 291 | 316 | }); |
| ... | ... | @@ -412,6 +437,8 @@ export function useThreadActions() { |
| 412 | 437 | |
| 413 | 438 | const deleteThread = useCallback( |
| 414 | 439 | async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => { |
| 440 | const permissionFailure = threadOperationFailure(target); | |
| 441 | if (permissionFailure) return permissionFailure; | |
| 415 | 442 | const resolved = resolveThreadTarget(target); |
| 416 | 443 | if (!resolved) { |
| 417 | 444 | // Thread not in main store (e.g. archived thread) — dispatch delete directly. |
| ... | ... | @@ -456,13 +483,22 @@ export function useThreadActions() { |
| 456 | 483 | const environmentConfig = appAtomRegistry |
| 457 | 484 | .get(environmentServerConfigsAtom) |
| 458 | 485 | .get(threadRef.environmentId); |
| 459 | // A Scratch thread's folder is not a git worktree, and deleting the | |
| 460 | // thread keeps its files. | |
| 461 | const canDeleteWorktree = | |
| 486 | const localApi = readLocalApi(); | |
| 487 | let canDeleteWorktree = false; | |
| 488 | if ( | |
| 462 | 489 | orphanedWorktreePath !== null && |
| 463 | 490 | threadProject !== null && |
| 464 | !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot); | |
| 465 | const localApi = readLocalApi(); | |
| 491 | !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot) && | |
| 492 | localApi | |
| 493 | ) { | |
| 494 | const sessionResult = await loadSessionState(threadRef.environmentId); | |
| 495 | const permissionFailure = threadOperationFailure(threadRef); | |
| 496 | if (permissionFailure) return permissionFailure; | |
| 497 | canDeleteWorktree = | |
| 498 | sessionResult._tag === "Success" && | |
| 499 | sessionResult.value.authenticated && | |
| 500 | sessionResult.value.scopes?.includes(AuthSourceControlWriteScope) === true; | |
| 501 | } | |
| 466 | 502 | let shouldDeleteWorktree = false; |
| 467 | 503 | const environmentSettings = environmentConfig?.settings; |
| 468 | 504 | const automaticWorktreeCleanup = environmentSettings |
| ... | ... | @@ -493,11 +529,6 @@ export function useThreadActions() { |
| 493 | 529 | }); |
| 494 | 530 | } |
| 495 | 531 | |
| 496 | await closeTerminal({ | |
| 497 | environmentId: threadRef.environmentId, | |
| 498 | input: { threadId: threadRef.threadId, deleteHistory: true }, | |
| 499 | }); | |
| 500 | ||
| 501 | 532 | const deletedThreadIds = deletedIds ?? new Set<ThreadId>(); |
| 502 | 533 | const currentRouteThreadRef = getCurrentRouteThreadRef(); |
| 503 | 534 | const shouldNavigateToFallback = |
| ... | ... | @@ -546,14 +577,26 @@ export function useThreadActions() { |
| 546 | 577 | return deleteResult; |
| 547 | 578 | } |
| 548 | 579 | |
| 549 | const removeResult = await removeWorktree({ | |
| 550 | environmentId: threadRef.environmentId, | |
| 551 | input: { | |
| 552 | cwd: threadProject.workspaceRoot, | |
| 553 | path: orphanedWorktreePath, | |
| 554 | force: true, | |
| 555 | }, | |
| 556 | }); | |
| 580 | const removeResult = readEnvironmentScope( | |
| 581 | threadRef.environmentId, | |
| 582 | AuthSourceControlWriteScope, | |
| 583 | ) | |
| 584 | ? await removeWorktree({ | |
| 585 | environmentId: threadRef.environmentId, | |
| 586 | input: { | |
| 587 | cwd: threadProject.workspaceRoot, | |
| 588 | path: orphanedWorktreePath, | |
| 589 | force: true, | |
| 590 | }, | |
| 591 | }) | |
| 592 | : AsyncResult.failure( | |
| 593 | Cause.fail( | |
| 594 | new EnvironmentAuthorizationError({ | |
| 595 | message: "This connection can no longer remove worktrees.", | |
| 596 | requiredScope: AuthSourceControlWriteScope, | |
| 597 | }), | |
| 598 | ), | |
| 599 | ); | |
| 557 | 600 | const refreshResult = |
| 558 | 601 | removeResult._tag === "Success" |
| 559 | 602 | ? await refreshVcsStatus({ |
| ... | ... | @@ -597,9 +640,9 @@ export function useThreadActions() { |
| 597 | 640 | clearComposerDraftForThread, |
| 598 | 641 | clearProjectDraftThreadById, |
| 599 | 642 | clearTerminalUiState, |
| 600 | closeTerminal, | |
| 601 | 643 | deleteThreadMutation, |
| 602 | 644 | getCurrentRouteThreadRef, |
| 645 | loadSessionState, | |
| 603 | 646 | refreshVcsStatus, |
| 604 | 647 | removeWorktree, |
| 605 | 648 | router, |
| ... | ... | @@ -797,6 +840,8 @@ export function useThreadActions() { |
| 797 | 840 | |
| 798 | 841 | const confirmAndUnpinThread = useCallback( |
| 799 | 842 | async (target: ScopedThreadRef) => { |
| 843 | const permissionFailure = threadOperationFailure(target); | |
| 844 | if (permissionFailure) return permissionFailure; | |
| 800 | 845 | const localApi = readLocalApi(); |
| 801 | 846 | const resolved = resolveThreadTarget(target); |
| 802 | 847 | const confirmationResult = await requestThreadUnpinConfirmation({ |
| ... | ... | @@ -930,6 +975,8 @@ export function useThreadActions() { |
| 930 | 975 | |
| 931 | 976 | const confirmAndDeleteThread = useCallback( |
| 932 | 977 | async (target: ScopedThreadRef) => { |
| 978 | const permissionFailure = threadOperationFailure(target); | |
| 979 | if (permissionFailure) return permissionFailure; | |
| 933 | 980 | const localApi = readLocalApi(); |
| 934 | 981 | const resolved = resolveThreadTarget(target); |
| 935 | 982 |
apps/web/src/hooks/useThreadActions.undo.test.ts+5| ... | ... | @@ -20,6 +20,11 @@ const router = vi.hoisted(() => ({ |
| 20 | 20 | navigate: vi.fn(async () => {}), |
| 21 | 21 | state: { matches: [{ params: {} as Record<string, string> }] }, |
| 22 | 22 | })); |
| 23 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); | |
| 24 | vi.mock("../state/session", async (original) => ({ | |
| 25 | ...(await original<typeof import("../state/session")>()), | |
| 26 | readEnvironmentScope: () => true, | |
| 27 | })); | |
| 23 | 28 | vi.mock("react", async (original) => ({ |
| 24 | 29 | ...(await original<typeof import("react")>()), |
| 25 | 30 | useCallback: (callback: unknown) => callback, |
apps/web/src/state/sourceControlActions.test.ts created+209| ... | ... | @@ -0,0 +1,209 @@ |
| 1 | import { | |
| 2 | AuthOrchestrationOperateScope, | |
| 3 | AuthSourceControlWriteScope, | |
| 4 | EnvironmentId, | |
| 5 | ThreadId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Cause from "effect/Cause"; | |
| 8 | import { AsyncResult } from "effect/reactivity"; | |
| 9 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 10 | ||
| 11 | const state = vi.hoisted(() => ({ | |
| 12 | scopes: new Map<string, Set<string>>(), | |
| 13 | requests: [] as { action: string; environmentId: string | null }[], | |
| 14 | })); | |
| 15 | ||
| 16 | vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); | |
| 17 | vi.mock("@effect/atom-react", () => ({ | |
| 18 | useAtomValue: () => ({ operation: null, error: null, isRunning: false }), | |
| 19 | })); | |
| 20 | vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); | |
| 21 | vi.mock("./session", () => ({ | |
| 22 | useEnvironmentScope: (environmentId: string, scope: string) => | |
| 23 | state.scopes.get(environmentId)?.has(scope) === true, | |
| 24 | readEnvironmentScope: (environmentId: string, scope: string) => | |
| 25 | state.scopes.get(environmentId)?.has(scope) === true, | |
| 26 | })); | |
| 27 | vi.mock("./query", () => ({ useEnvironmentQuery: () => ({ refresh: () => {} }) })); | |
| 28 | vi.mock("./use-atom-command", () => ({ | |
| 29 | useAtomCommand: | |
| 30 | (command: { action: string; environmentId?: string }) => | |
| 31 | async (input: { environmentId?: string }) => { | |
| 32 | state.requests.push({ | |
| 33 | action: command.action, | |
| 34 | environmentId: input.environmentId ?? command.environmentId ?? null, | |
| 35 | }); | |
| 36 | return AsyncResult.success({}); | |
| 37 | }, | |
| 38 | })); | |
| 39 | vi.mock("./vcs", () => ({ | |
| 40 | vcsEnvironment: { init: { action: "init" }, pull: { action: "pull" }, status: () => "status" }, | |
| 41 | vcsActionManager: { | |
| 42 | stateAtom: () => "state", | |
| 43 | resetError: () => {}, | |
| 44 | track: (_registry: unknown, _target: unknown, _input: unknown, execute: () => unknown) => | |
| 45 | execute(), | |
| 46 | runStackedAction: (scope: { environmentId: string }) => ({ action: "stack", ...scope }), | |
| 47 | }, | |
| 48 | })); | |
| 49 | vi.mock("./sourceControl", () => ({ | |
| 50 | sourceControlEnvironment: { publishRepository: { action: "publish" } }, | |
| 51 | })); | |
| 52 | vi.mock("./git", () => ({ | |
| 53 | gitEnvironment: { preparePullRequestThread: { action: "prepare" } }, | |
| 54 | })); | |
| 55 | ||
| 56 | import { | |
| 57 | useGitStackedAction, | |
| 58 | usePreparePullRequestThreadAction, | |
| 59 | useSourceControlPublishRepositoryAction, | |
| 60 | useVcsInitAction, | |
| 61 | useVcsPullAction, | |
| 62 | } from "./sourceControlActions"; | |
| 63 | ||
| 64 | const primary = EnvironmentId.make("primary"); | |
| 65 | const secondary = EnvironmentId.make("secondary"); | |
| 66 | const scope = { environmentId: secondary, cwd: "/repo" }; | |
| 67 | const threadId = ThreadId.make("thread"); | |
| 68 | const cases = [ | |
| 69 | { | |
| 70 | name: "init", | |
| 71 | create: () => { | |
| 72 | const action = useVcsInitAction(scope); | |
| 73 | return { isAllowed: action.isAllowed, run: () => action.run() }; | |
| 74 | }, | |
| 75 | }, | |
| 76 | { | |
| 77 | name: "pull", | |
| 78 | create: () => { | |
| 79 | const action = useVcsPullAction(scope); | |
| 80 | return { isAllowed: action.isAllowed, run: () => action.run() }; | |
| 81 | }, | |
| 82 | }, | |
| 83 | { | |
| 84 | name: "stack", | |
| 85 | create: () => { | |
| 86 | const action = useGitStackedAction(scope); | |
| 87 | return { | |
| 88 | isAllowed: action.isAllowed, | |
| 89 | run: () => action.run({ actionId: "action", action: "commit" }), | |
| 90 | }; | |
| 91 | }, | |
| 92 | }, | |
| 93 | { | |
| 94 | name: "publish", | |
| 95 | create: () => { | |
| 96 | const action = useSourceControlPublishRepositoryAction(scope); | |
| 97 | return { | |
| 98 | isAllowed: action.isAllowed, | |
| 99 | run: () => | |
| 100 | action.run({ | |
| 101 | provider: "github", | |
| 102 | repository: "owner/repo", | |
| 103 | visibility: "private", | |
| 104 | remoteName: "origin", | |
| 105 | protocol: "ssh", | |
| 106 | }), | |
| 107 | }; | |
| 108 | }, | |
| 109 | }, | |
| 110 | { | |
| 111 | name: "prepare", | |
| 112 | create: () => { | |
| 113 | const action = usePreparePullRequestThreadAction(scope); | |
| 114 | return { | |
| 115 | isAllowed: action.isAllowed, | |
| 116 | run: () => action.run({ reference: "1", mode: "local" }), | |
| 117 | }; | |
| 118 | }, | |
| 119 | }, | |
| 120 | ] as const; | |
| 121 | ||
| 122 | beforeEach(() => { | |
| 123 | state.scopes = new Map([ | |
| 124 | [primary, new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope])], | |
| 125 | [secondary, new Set<string>()], | |
| 126 | ]); | |
| 127 | state.requests = []; | |
| 128 | }); | |
| 129 | ||
| 130 | describe("source control callback grants", () => { | |
| 131 | it.each(cases)("$name uses the selected environment's permission", async ({ create }) => { | |
| 132 | const action = create(); | |
| 133 | expect(action.isAllowed).toBe(false); | |
| 134 | expect((await action.run())._tag).toBe("Failure"); | |
| 135 | expect(state.requests).toEqual([]); | |
| 136 | }); | |
| 137 | ||
| 138 | it.each(cases)("$name rejects a retained callback after revocation", async ({ create }) => { | |
| 139 | state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); | |
| 140 | const action = create(); | |
| 141 | expect(action.isAllowed).toBe(true); | |
| 142 | state.scopes.get(secondary)!.clear(); | |
| 143 | expect((await action.run())._tag).toBe("Failure"); | |
| 144 | expect(state.requests).toEqual([]); | |
| 145 | expect(create().isAllowed).toBe(false); | |
| 146 | }); | |
| 147 | ||
| 148 | it.each(cases)("$name accepts a retained callback after a grant", async ({ name, create }) => { | |
| 149 | const action = create(); | |
| 150 | expect(action.isAllowed).toBe(false); | |
| 151 | state.scopes.get(primary)!.clear(); | |
| 152 | state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); | |
| 153 | expect((await action.run())._tag).toBe("Success"); | |
| 154 | expect(state.requests).toEqual([{ action: name, environmentId: secondary }]); | |
| 155 | expect(create().isAllowed).toBe(true); | |
| 156 | }); | |
| 157 | }); | |
| 158 | ||
| 159 | describe("pull request worktree attachment permission", () => { | |
| 160 | it.each([ | |
| 161 | ["local", threadId, false, true], | |
| 162 | ["worktree", undefined, false, true], | |
| 163 | ["worktree", threadId, false, false], | |
| 164 | ["worktree", threadId, true, true], | |
| 165 | ] as const)( | |
| 166 | "mode %s / thread %s / task grant %s", | |
| 167 | async (mode, attachedThreadId, operate, allowed) => { | |
| 168 | state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); | |
| 169 | if (operate) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | |
| 170 | const result = await usePreparePullRequestThreadAction(scope).run({ | |
| 171 | reference: "1", | |
| 172 | mode, | |
| 173 | ...(attachedThreadId !== undefined ? { threadId: attachedThreadId } : {}), | |
| 174 | }); | |
| 175 | expect(result._tag).toBe(allowed ? "Success" : "Failure"); | |
| 176 | expect(state.requests).toHaveLength(allowed ? 1 : 0); | |
| 177 | if (result._tag === "Failure") { | |
| 178 | expect(Cause.squash(result.cause)).toMatchObject({ | |
| 179 | requiredScope: AuthOrchestrationOperateScope, | |
| 180 | }); | |
| 181 | } | |
| 182 | }, | |
| 183 | ); | |
| 184 | ||
| 185 | it.each([false, true])( | |
| 186 | "reads the fresh task grant before attaching a worktree: %s", | |
| 187 | async (allowed) => { | |
| 188 | state.scopes.get(secondary)!.add(AuthSourceControlWriteScope); | |
| 189 | if (!allowed) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | |
| 190 | const action = usePreparePullRequestThreadAction(scope); | |
| 191 | if (allowed) state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | |
| 192 | else state.scopes.get(secondary)!.delete(AuthOrchestrationOperateScope); | |
| 193 | const result = await action.run({ reference: "1", mode: "worktree", threadId }); | |
| 194 | expect(result._tag).toBe(allowed ? "Success" : "Failure"); | |
| 195 | expect(state.requests).toHaveLength(allowed ? 1 : 0); | |
| 196 | }, | |
| 197 | ); | |
| 198 | ||
| 199 | it("still requires source-control permission when the task grant is present", async () => { | |
| 200 | state.scopes.get(secondary)!.add(AuthOrchestrationOperateScope); | |
| 201 | const result = await usePreparePullRequestThreadAction(scope).run({ | |
| 202 | reference: "1", | |
| 203 | mode: "worktree", | |
| 204 | threadId, | |
| 205 | }); | |
| 206 | expect(result._tag).toBe("Failure"); | |
| 207 | expect(state.requests).toEqual([]); | |
| 208 | }); | |
| 209 | }); |
apps/web/src/state/sourceControlActions.ts+44-7| ... | ... | @@ -9,12 +9,15 @@ import { |
| 9 | 9 | type VcsActionOperation, |
| 10 | 10 | type RunVcsStackedActionInput, |
| 11 | 11 | } from "@t3tools/client-runtime/state/vcs"; |
| 12 | import type { | |
| 13 | EnvironmentId, | |
| 14 | GitResolvePullRequestResult, | |
| 15 | SourceControlCloneProtocol, | |
| 16 | SourceControlRepositoryVisibility, | |
| 17 | ThreadId, | |
| 12 | import { | |
| 13 | AuthOrchestrationOperateScope, | |
| 14 | AuthSourceControlWriteScope, | |
| 15 | EnvironmentAuthorizationError, | |
| 16 | type EnvironmentId, | |
| 17 | type GitResolvePullRequestResult, | |
| 18 | type SourceControlCloneProtocol, | |
| 19 | type SourceControlRepositoryVisibility, | |
| 20 | type ThreadId, | |
| 18 | 21 | } from "@t3tools/contracts"; |
| 19 | 22 | import * as Cause from "effect/Cause"; |
| 20 | 23 | import * as Option from "effect/Option"; |
| ... | ... | @@ -24,6 +27,7 @@ import { useCallback } from "react"; |
| 24 | 27 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 25 | 28 | import { gitEnvironment } from "./git"; |
| 26 | 29 | import { useEnvironmentQuery } from "./query"; |
| 30 | import { readEnvironmentScope, useEnvironmentScope } from "./session"; | |
| 27 | 31 | import { sourceControlEnvironment } from "./sourceControl"; |
| 28 | 32 | import { useAtomCommand } from "./use-atom-command"; |
| 29 | 33 | import { vcsActionManager, vcsEnvironment } from "./vcs"; |
| ... | ... | @@ -45,11 +49,15 @@ interface SourceControlActionState< |
| 45 | 49 | R extends AtomCommandResult<unknown, unknown>, |
| 46 | 50 | > { |
| 47 | 51 | readonly isPending: boolean; |
| 52 | readonly isAllowed: boolean; | |
| 48 | 53 | readonly error: unknown; |
| 49 | 54 | readonly run: ( |
| 50 | 55 | ...args: TArgs |
| 51 | 56 | ) => Promise< |
| 52 | AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R> | VcsActionUnavailableError> | |
| 57 | AtomCommandResult< | |
| 58 | AtomCommandSuccess<R>, | |
| 59 | AtomCommandFailure<R> | VcsActionUnavailableError | EnvironmentAuthorizationError | |
| 60 | > | |
| 53 | 61 | >; |
| 54 | 62 | readonly resetError: () => void; |
| 55 | 63 | } |
| ... | ... | @@ -73,6 +81,7 @@ function useAction< |
| 73 | 81 | readonly onSuccess?: () => void; |
| 74 | 82 | readonly managedExternally?: boolean; |
| 75 | 83 | }): SourceControlActionState<TArgs, R> { |
| 84 | const isAllowed = useEnvironmentScope(input.scope.environmentId, AuthSourceControlWriteScope); | |
| 76 | 85 | const operation = ACTION_OPERATION[input.kind]; |
| 77 | 86 | const state = useAtomValue(vcsActionManager.stateAtom(input.scope)); |
| 78 | 87 | const ownsState = state.operation === operation; |
| ... | ... | @@ -83,6 +92,19 @@ function useAction< |
| 83 | 92 | |
| 84 | 93 | const run = useCallback( |
| 85 | 94 | async (...args: TArgs) => { |
| 95 | if ( | |
| 96 | input.scope.environmentId === null || | |
| 97 | !readEnvironmentScope(input.scope.environmentId, AuthSourceControlWriteScope) | |
| 98 | ) { | |
| 99 | return AsyncResult.failure<never, EnvironmentAuthorizationError>( | |
| 100 | Cause.fail( | |
| 101 | new EnvironmentAuthorizationError({ | |
| 102 | requiredScope: AuthSourceControlWriteScope, | |
| 103 | message: "This connection cannot change source control.", | |
| 104 | }), | |
| 105 | ), | |
| 106 | ); | |
| 107 | } | |
| 86 | 108 | const execute = async (): Promise< |
| 87 | 109 | AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R>> |
| 88 | 110 | > => { |
| ... | ... | @@ -108,6 +130,7 @@ function useAction< |
| 108 | 130 | ); |
| 109 | 131 | |
| 110 | 132 | return { |
| 133 | isAllowed, | |
| 111 | 134 | error: ownsState ? state.error : null, |
| 112 | 135 | isPending: ownsState && state.isRunning, |
| 113 | 136 | resetError, |
| ... | ... | @@ -307,6 +330,20 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop |
| 307 | 330 | ), |
| 308 | 331 | ); |
| 309 | 332 | } |
| 333 | if ( | |
| 334 | input.mode === "worktree" && | |
| 335 | input.threadId !== undefined && | |
| 336 | !readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) | |
| 337 | ) { | |
| 338 | return AsyncResult.failure<never, EnvironmentAuthorizationError>( | |
| 339 | Cause.fail( | |
| 340 | new EnvironmentAuthorizationError({ | |
| 341 | requiredScope: AuthOrchestrationOperateScope, | |
| 342 | message: "This connection cannot change threads.", | |
| 343 | }), | |
| 344 | ), | |
| 345 | ); | |
| 346 | } | |
| 310 | 347 | return preparePullRequestThread({ |
| 311 | 348 | environmentId: target.environmentId, |
| 312 | 349 | input: { |
docs/user/remote-access.md+6| ... | ... | @@ -281,6 +281,12 @@ Grouping checkouts does not combine their permissions. Shared project settings |
| 281 | 281 | require `orchestration:operate` on every member environment; actions on one |
| 282 | 282 | checkout use that checkout's permissions. |
| 283 | 283 | |
| 284 | `source-control:write` covers direct Git and pull request changes made from the | |
| 285 | client: pushing, switching or creating branches, cloning, and removing | |
| 286 | worktrees. It does not restrict what a task does. Starting a task in a new | |
| 287 | worktree still creates that branch and worktree with `orchestration:operate`, | |
| 288 | and the agent it runs can use Git however the environment allows. | |
| 289 | ||
| 284 | 290 | Settings changes, provider management, and environment maintenance can be granted |
| 285 | 291 | separately from access administration. New standard pairings include these |
| 286 | 292 | permissions. Existing clients keep their original grants after an update; to |
packages/client-runtime/src/rpc/client.ts+1-1| ... | ... | @@ -203,7 +203,7 @@ export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < |
| 203 | 203 | return yield* method(input).pipe(Effect.ensuring(completeObservation)); |
| 204 | 204 | }); |
| 205 | 205 | |
| 206 | function runStreamGuarded<TTag extends EnvironmentStreamCommandRpcTag>( | |
| 206 | export function runStreamGuarded<TTag extends EnvironmentStreamCommandRpcTag>( | |
| 207 | 207 | tag: TTag, |
| 208 | 208 | input: EnvironmentRpcInput<TTag>, |
| 209 | 209 | ): Stream.Stream< |
packages/client-runtime/src/state/commandPermissions.test.ts+43| ... | ... | @@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { vi } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | 4 | AuthOrchestrationOperateScope, |
| 5 | AuthSourceControlWriteScope, | |
| 6 | ThreadId, | |
| 5 | 7 | EnvironmentId, |
| 6 | 8 | ScheduledTaskId, |
| 7 | 9 | WS_METHODS, |
| ... | ... | @@ -150,3 +152,44 @@ describe("command permissions", () => { |
| 150 | 152 | } |
| 151 | 153 | }); |
| 152 | 154 | }); |
| 155 | ||
| 156 | it.effect( | |
| 157 | "requires source control alone for git, and both grants when attaching a worktree to a thread", | |
| 158 | () => | |
| 159 | Effect.scoped( | |
| 160 | Effect.gen(function* () { | |
| 161 | const registry = yield* setup; | |
| 162 | registry.set(sessions(env), AsyncResult.success(grant(true))); | |
| 163 | const git = createCommandPermissions(runtime, WS_METHODS.vcsInit); | |
| 164 | expect((yield* git.authorize(registry, env).pipe(Effect.flip)).requiredScope).toBe( | |
| 165 | AuthSourceControlWriteScope, | |
| 166 | ); | |
| 167 | registry.set( | |
| 168 | sessions(env), | |
| 169 | AsyncResult.success({ ...grant(false), scopes: [AuthSourceControlWriteScope] }), | |
| 170 | ); | |
| 171 | yield* git.authorize(registry, env); | |
| 172 | const prepare = createCommandPermissions(runtime, WS_METHODS.gitPreparePullRequestThread); | |
| 173 | const input = { | |
| 174 | cwd: "/repo", | |
| 175 | reference: "123", | |
| 176 | mode: "worktree", | |
| 177 | threadId: ThreadId.make("thread"), | |
| 178 | }; | |
| 179 | expect(registry.get(prepare.permissionAtom(env, input))).toBe(false); | |
| 180 | expect( | |
| 181 | (yield* prepare.authorize(registry, env, input).pipe(Effect.flip)).requiredScope, | |
| 182 | ).toBe(AuthOrchestrationOperateScope); | |
| 183 | yield* prepare.authorize(registry, env, { ...input, threadId: undefined }); | |
| 184 | registry.set( | |
| 185 | sessions(env), | |
| 186 | AsyncResult.success({ | |
| 187 | ...grant(true), | |
| 188 | scopes: [AuthSourceControlWriteScope, AuthOrchestrationOperateScope], | |
| 189 | }), | |
| 190 | ); | |
| 191 | expect(registry.get(prepare.permissionAtom(env, input))).toBe(true); | |
| 192 | yield* prepare.authorize(registry, env, input); | |
| 193 | }), | |
| 194 | ), | |
| 195 | ); |
packages/client-runtime/src/state/pullRequests.test.ts+91| ... | ... | @@ -1,9 +1,14 @@ |
| 1 | import { createCommandPermissions } from "./commandPermissions.ts"; | |
| 2 | import { RpcPermissionGuard } from "../rpc/client.ts"; | |
| 3 | import { vi } from "vite-plus/test"; | |
| 1 | 4 | import { |
| 2 | 5 | EnvironmentId, |
| 6 | AuthSourceControlWriteScope, | |
| 3 | 7 | ProjectId, |
| 4 | 8 | PullRequestOperationError, |
| 5 | 9 | WS_METHODS, |
| 6 | 10 | type PullRequestStack, |
| 11 | type AuthSessionState, | |
| 7 | 12 | } from "@t3tools/contracts"; |
| 8 | 13 | import { expect, it } from "@effect/vitest"; |
| 9 | 14 | import * as Data from "effect/Data"; |
| ... | ... | @@ -194,6 +199,7 @@ for (const scenario of [ |
| 194 | 199 | : route(WS_METHODS.pullRequestsRunAction, input) |
| 195 | 200 | ).pipe( |
| 196 | 201 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 202 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 197 | 203 | Effect.provideService(GitHubRoutingPermissions, { |
| 198 | 204 | ...trustedRouting, |
| 199 | 205 | get: () => Effect.succeed(trusted ? "read-write" : "off"), |
| ... | ... | @@ -301,6 +307,7 @@ it.effect("shares active read identity probes while keeping later reads and muta |
| 301 | 307 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 302 | 308 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 303 | 309 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 310 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 304 | 311 | ); |
| 305 | 312 | }), |
| 306 | 313 | ), |
| ... | ... | @@ -554,6 +561,7 @@ it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)( |
| 554 | 561 | }).pipe( |
| 555 | 562 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 556 | 563 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 564 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 557 | 565 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 558 | 566 | ); |
| 559 | 567 | const environment = provider === "github" ? "local" : "origin"; |
| ... | ... | @@ -720,10 +728,12 @@ it.effect.each(["default", "origin-off", "destination-off", "read-only"] as cons |
| 720 | 728 | }).pipe( |
| 721 | 729 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 722 | 730 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 731 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 723 | 732 | ); |
| 724 | 733 | yield* permission === "default" |
| 725 | 734 | ? request |
| 726 | 735 | : request.pipe( |
| 736 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 727 | 737 | Effect.provideService(GitHubRoutingPermissions, { |
| 728 | 738 | ...trustedRouting, |
| 729 | 739 | get: (entry) => |
| ... | ... | @@ -842,6 +852,7 @@ it.effect.each( |
| 842 | 852 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 843 | 853 | // This also represents a user enabling the stale catalog entry after re-resolution. |
| 844 | 854 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 855 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 845 | 856 | ); |
| 846 | 857 | yield* stored === "unavailable" |
| 847 | 858 | ? route |
| ... | ... | @@ -897,6 +908,7 @@ it.live.each(["origin", "alternate"] as const)( |
| 897 | 908 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 898 | 909 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 899 | 910 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 911 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 900 | 912 | Effect.flip, |
| 901 | 913 | ); |
| 902 | 914 | expect(error._tag).toBe("PullRequestOperationError"); |
| ... | ... | @@ -958,6 +970,7 @@ it.effect.each( |
| 958 | 970 | }).pipe( |
| 959 | 971 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 960 | 972 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 973 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 961 | 974 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 962 | 975 | ); |
| 963 | 976 | const fiber = yield* request.pipe(Effect.forkChild); |
| ... | ... | @@ -1014,6 +1027,7 @@ it.live("keeps source workspace metadata when an alternate answers a detail read |
| 1014 | 1027 | }).pipe( |
| 1015 | 1028 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 1016 | 1029 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 1030 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 1017 | 1031 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 1018 | 1032 | ); |
| 1019 | 1033 | expect(result).toEqual({ |
| ... | ... | @@ -1101,6 +1115,7 @@ it.live( |
| 1101 | 1115 | }).pipe( |
| 1102 | 1116 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 1103 | 1117 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 1118 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 1104 | 1119 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 1105 | 1120 | ); |
| 1106 | 1121 | }), |
| ... | ... | @@ -1649,6 +1664,7 @@ it.effect.each([false, true])( |
| 1649 | 1664 | }).pipe( |
| 1650 | 1665 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 1651 | 1666 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 1667 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | |
| 1652 | 1668 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 1653 | 1669 | ); |
| 1654 | 1670 | expect(result).toEqual({ state: "open", checks: [] }); |
| ... | ... | @@ -1980,3 +1996,78 @@ it.effect("refreshes stack state after reopening and head SHAs after a turn", () |
| 1980 | 1996 | }), |
| 1981 | 1997 | ), |
| 1982 | 1998 | ); |
| 1999 | ||
| 2000 | // Transport fixtures have a source-control-only session; authorization edge cases | |
| 2001 | // are exercised by commandPermissions.test.ts. | |
| 2002 | vi.mock("./session.ts", () => ({ | |
| 2003 | createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), | |
| 2004 | })); | |
| 2005 | const grantedSessions = Atom.family((_id: EnvironmentId) => | |
| 2006 | Atom.make<AsyncResult.AsyncResult<AuthSessionState>>( | |
| 2007 | AsyncResult.success({ | |
| 2008 | authenticated: true, | |
| 2009 | auth: { | |
| 2010 | policy: "remote-reachable" as const, | |
| 2011 | bootstrapMethods: [], | |
| 2012 | sessionMethods: [], | |
| 2013 | sessionCookieName: "test", | |
| 2014 | }, | |
| 2015 | scopes: [AuthSourceControlWriteScope], | |
| 2016 | }), | |
| 2017 | ), | |
| 2018 | ); | |
| 2019 | ||
| 2020 | it.effect("denies a routed write when only the origin has source-control permission", () => | |
| 2021 | Effect.scoped( | |
| 2022 | Effect.gen(function* () { | |
| 2023 | let writes = 0; | |
| 2024 | const identity = { host: "github.com", provider: "github", viewer: "test", accountId: "123" }; | |
| 2025 | const client = { | |
| 2026 | [WS_METHODS.pullRequestsRouting]: () => Effect.succeed(identity), | |
| 2027 | [WS_METHODS.pullRequestsRoutingIdentity]: () => Effect.succeed(identity), | |
| 2028 | [WS_METHODS.pullRequestsRunAction]: () => | |
| 2029 | Effect.sync(() => { | |
| 2030 | writes++; | |
| 2031 | }), | |
| 2032 | } as unknown as WsRpcProtocolClient; | |
| 2033 | const fixture = yield* makeTestRuntime(client, client); | |
| 2034 | const local = EnvironmentId.make("local-environment"); | |
| 2035 | const stop = fixture.registry.mount(grantedSessions(local)); | |
| 2036 | yield* Effect.addFinalizer(() => Effect.sync(stop)); | |
| 2037 | fixture.registry.set( | |
| 2038 | grantedSessions(local), | |
| 2039 | AsyncResult.success({ | |
| 2040 | authenticated: true, | |
| 2041 | scopes: [], | |
| 2042 | auth: { | |
| 2043 | policy: "remote-reachable" as const, | |
| 2044 | bootstrapMethods: [], | |
| 2045 | sessionMethods: [], | |
| 2046 | sessionCookieName: "test", | |
| 2047 | }, | |
| 2048 | }), | |
| 2049 | ); | |
| 2050 | const error = yield* createPullRequestRouter()(WS_METHODS.pullRequestsRunAction, { | |
| 2051 | projectId: ProjectId.make("project"), | |
| 2052 | repository: "acme/repo", | |
| 2053 | number: 1, | |
| 2054 | action: "merge", | |
| 2055 | }).pipe( | |
| 2056 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, fixture.environmentRegistry), | |
| 2057 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, fixture.supervisor), | |
| 2058 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | |
| 2059 | Effect.provideService(RpcPermissionGuard, { | |
| 2060 | authorize: (id, method, input) => | |
| 2061 | createCommandPermissions(fixture.runtime, method).authorize( | |
| 2062 | fixture.registry, | |
| 2063 | id, | |
| 2064 | input, | |
| 2065 | ), | |
| 2066 | }), | |
| 2067 | Effect.flip, | |
| 2068 | ); | |
| 2069 | expect(error._tag).toBe("EnvironmentAuthorizationError"); | |
| 2070 | expect(writes).toBe(0); | |
| 2071 | }), | |
| 2072 | ), | |
| 2073 | ); |
packages/client-runtime/src/state/pullRequests.ts+5-6| ... | ... | @@ -19,7 +19,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| 19 | 19 | |
| 20 | 20 | import { |
| 21 | 21 | createAtomCommandScheduler, |
| 22 | createEnvironmentCommand, | |
| 23 | 22 | createEnvironmentRpcCommand, |
| 24 | 23 | createEnvironmentRpcQueryAtomFamily, |
| 25 | 24 | createEnvironmentRpcSubscriptionAtomFamily, |
| ... | ... | @@ -320,7 +319,8 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 320 | 319 | JSON.stringify([environmentId, input.projectId, input.repository, input.number]), |
| 321 | 320 | }, |
| 322 | 321 | }), |
| 323 | runAction: createEnvironmentCommand(runtime, { | |
| 322 | runAction: createEnvironmentRpcCommand(runtime, { | |
| 323 | tag: WS_METHODS.pullRequestsRunAction, | |
| 324 | 324 | label: "environment-data:pull-requests:run-action", |
| 325 | 325 | // Preparation belongs to the write's lane. Refreshable queries would restart it after |
| 326 | 326 | // every preceding action, and preparing outside the lane could reorder the clicks. |
| ... | ... | @@ -328,8 +328,6 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 328 | 328 | input: PullRequestActionInput & { |
| 329 | 329 | readonly resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; |
| 330 | 330 | }, |
| 331 | registry, | |
| 332 | environmentId, | |
| 333 | 331 | ) => |
| 334 | 332 | Effect.gen(function* () { |
| 335 | 333 | const { resolveMergeMethod, ...actionInput } = input; |
| ... | ... | @@ -369,9 +367,10 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 369 | 367 | }); |
| 370 | 368 | preparedInput = { ...actionInput, mergeMethod }; |
| 371 | 369 | } |
| 372 | yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput); | |
| 373 | registry.refresh(preview({ environmentId, input: actionInput })); | |
| 370 | return yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput); | |
| 374 | 371 | }), |
| 372 | onSuccess: ({ environmentId, input }, registry) => | |
| 373 | Effect.sync(() => registry.refresh(preview({ environmentId, input }))), | |
| 375 | 374 | scheduler: commandScheduler, |
| 376 | 375 | concurrency: serialPerEnvironment, |
| 377 | 376 | }), |
packages/client-runtime/src/state/runtime.ts+12-7| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { createCommandPermissions } from "./commandPermissions.ts"; |
| 2 | import { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; | |
| 2 | import { followStreamInEnvironment } from "./environmentStreams.ts"; | |
| 3 | 3 | export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; |
| 4 | 4 | import { |
| 5 | 5 | type ClientGuardedRpcTag, |
| ... | ... | @@ -717,13 +717,18 @@ export function createEnvironmentRpcSubscriptionAtomFamily< |
| 717 | 717 | }); |
| 718 | 718 | } |
| 719 | 719 | |
| 720 | export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnaryRpcTag>( | |
| 720 | export function createEnvironmentRpcCommand< | |
| 721 | R, | |
| 722 | ER, | |
| 723 | TTag extends EnvironmentUnaryRpcTag, | |
| 724 | Input extends EnvironmentRpcInput<TTag> = EnvironmentRpcInput<TTag>, | |
| 725 | >( | |
| 721 | 726 | runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>, |
| 722 | 727 | options: { |
| 723 | 728 | readonly label: string; |
| 724 | 729 | readonly tag: TTag; |
| 725 | 730 | readonly execute?: ( |
| 726 | input: EnvironmentRpcInput<TTag>, | |
| 731 | input: Input, | |
| 727 | 732 | ) => Effect.Effect< |
| 728 | 733 | EnvironmentRpcSuccess<TTag>, |
| 729 | 734 | EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError, |
| ... | ... | @@ -732,19 +737,19 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary |
| 732 | 737 | readonly scheduler?: AtomCommandScheduler; |
| 733 | 738 | readonly concurrency?: AtomCommandConcurrency<{ |
| 734 | 739 | readonly environmentId: EnvironmentIdType; |
| 735 | readonly input: EnvironmentRpcInput<TTag>; | |
| 740 | readonly input: NoInfer<Input>; | |
| 736 | 741 | }>; |
| 737 | 742 | readonly onSuccess?: ( |
| 738 | 743 | target: { |
| 739 | 744 | readonly environmentId: EnvironmentIdType; |
| 740 | readonly input: EnvironmentRpcInput<TTag>; | |
| 745 | readonly input: NoInfer<Input>; | |
| 741 | 746 | }, |
| 742 | 747 | registry: AtomRegistry.AtomRegistry, |
| 743 | 748 | ) => Effect.Effect<void, never, R>; |
| 744 | 749 | readonly onSettled?: ( |
| 745 | 750 | target: { |
| 746 | 751 | readonly environmentId: EnvironmentIdType; |
| 747 | readonly input: EnvironmentRpcInput<TTag>; | |
| 752 | readonly input: NoInfer<Input>; | |
| 748 | 753 | }, |
| 749 | 754 | registry: AtomRegistry.AtomRegistry, |
| 750 | 755 | ) => Effect.Effect<void, never, R>; |
| ... | ... | @@ -755,7 +760,7 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary |
| 755 | 760 | label: options.label, |
| 756 | 761 | ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), |
| 757 | 762 | ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), |
| 758 | execute: (input: EnvironmentRpcInput<TTag>, registry, environmentId) => { | |
| 763 | execute: (input: Input, registry, environmentId) => { | |
| 759 | 764 | const target = { |
| 760 | 765 | environmentId, |
| 761 | 766 | input, |
packages/client-runtime/src/state/sourceControl.test.ts+22| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | import { vi } from "vite-plus/test"; | |
| 1 | 2 | import { |
| 2 | 3 | EnvironmentId, |
| 4 | AuthSourceControlWriteScope, | |
| 3 | 5 | WS_METHODS, |
| 4 | 6 | type SourceControlPublishRepositoryResult, |
| 5 | 7 | } from "@t3tools/contracts"; |
| ... | ... | @@ -168,3 +170,23 @@ describe("source control environment atoms", () => { |
| 168 | 170 | ), |
| 169 | 171 | ); |
| 170 | 172 | }); |
| 173 | ||
| 174 | // Transport fixtures have a source-control-only session; authorization edge cases | |
| 175 | // are exercised by commandPermissions.test.ts. | |
| 176 | vi.mock("./session.ts", () => ({ | |
| 177 | createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), | |
| 178 | })); | |
| 179 | const grantedSessions = Atom.family((_id: EnvironmentId) => | |
| 180 | Atom.make( | |
| 181 | AsyncResult.success({ | |
| 182 | authenticated: true, | |
| 183 | auth: { | |
| 184 | policy: "remote-reachable", | |
| 185 | bootstrapMethods: [], | |
| 186 | sessionMethods: [], | |
| 187 | sessionCookieName: "test", | |
| 188 | }, | |
| 189 | scopes: [AuthSourceControlWriteScope], | |
| 190 | }), | |
| 191 | ), | |
| 192 | ); |
packages/client-runtime/src/state/vcs.test.ts+22| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | import { vi } from "vite-plus/test"; | |
| 1 | 2 | import { |
| 2 | 3 | EnvironmentId, |
| 4 | AuthSourceControlWriteScope, | |
| 3 | 5 | WS_METHODS, |
| 4 | 6 | type VcsListRefsInput, |
| 5 | 7 | type VcsListRefsResult, |
| ... | ... | @@ -643,3 +645,23 @@ describe("cached VCS refs", () => { |
| 643 | 645 | ), |
| 644 | 646 | ); |
| 645 | 647 | }); |
| 648 | ||
| 649 | // Transport fixtures have a source-control-only session; authorization edge cases | |
| 650 | // are exercised by commandPermissions.test.ts. | |
| 651 | vi.mock("./session.ts", () => ({ | |
| 652 | createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), | |
| 653 | })); | |
| 654 | const grantedSessions = Atom.family((_id: EnvironmentId) => | |
| 655 | Atom.make( | |
| 656 | AsyncResult.success({ | |
| 657 | authenticated: true, | |
| 658 | auth: { | |
| 659 | policy: "remote-reachable", | |
| 660 | bootstrapMethods: [], | |
| 661 | sessionMethods: [], | |
| 662 | sessionCookieName: "test", | |
| 663 | }, | |
| 664 | scopes: [AuthSourceControlWriteScope], | |
| 665 | }), | |
| 666 | ), | |
| 667 | ); |
packages/client-runtime/src/state/vcsAction.test.ts+22| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | import { vi } from "vite-plus/test"; | |
| 1 | 2 | import { |
| 2 | 3 | EnvironmentId, |
| 4 | AuthSourceControlWriteScope, | |
| 3 | 5 | ThreadId, |
| 4 | 6 | ProjectId, |
| 5 | 7 | WS_METHODS, |
| ... | ... | @@ -718,3 +720,23 @@ describe("vcsActionState", () => { |
| 718 | 720 | ), |
| 719 | 721 | ); |
| 720 | 722 | }); |
| 723 | ||
| 724 | // Transport fixtures have a source-control-only session; authorization edge cases | |
| 725 | // are exercised by commandPermissions.test.ts. | |
| 726 | vi.mock("./session.ts", () => ({ | |
| 727 | createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), | |
| 728 | })); | |
| 729 | const grantedSessions = Atom.family((_id: EnvironmentId) => | |
| 730 | Atom.make( | |
| 731 | AsyncResult.success({ | |
| 732 | authenticated: true, | |
| 733 | auth: { | |
| 734 | policy: "remote-reachable", | |
| 735 | bootstrapMethods: [], | |
| 736 | sessionMethods: [], | |
| 737 | sessionCookieName: "test", | |
| 738 | }, | |
| 739 | scopes: [AuthSourceControlWriteScope], | |
| 740 | }), | |
| 741 | ), | |
| 742 | ); |
packages/client-runtime/src/state/vcsAction.ts+7-2| ... | ... | @@ -19,13 +19,14 @@ import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; |
| 19 | 19 | |
| 20 | 20 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 21 | 21 | import * as Persistence from "../platform/persistence.ts"; |
| 22 | import { runStream } from "../rpc/client.ts"; | |
| 22 | import { runStreamGuarded, RpcPermissionGuard } from "../rpc/client.ts"; | |
| 23 | 23 | import { |
| 24 | 24 | createRuntimeCommand, |
| 25 | 25 | runStreamInEnvironment, |
| 26 | 26 | type AtomCommand, |
| 27 | 27 | type AtomCommandResult, |
| 28 | 28 | } from "./runtime.ts"; |
| 29 | import { createCommandPermissions } from "./commandPermissions.ts"; | |
| 29 | 30 | import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; |
| 30 | 31 | import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts"; |
| 31 | 32 | |
| ... | ... | @@ -483,7 +484,7 @@ export function createVcsActionManager<R, E>( |
| 483 | 484 | return consumeVcsActionProgress( |
| 484 | 485 | runStreamInEnvironment( |
| 485 | 486 | target.environmentId, |
| 486 | runStream(WS_METHODS.gitRunStackedAction, rpcInput), | |
| 487 | runStreamGuarded(WS_METHODS.gitRunStackedAction, rpcInput), | |
| 487 | 488 | ), |
| 488 | 489 | { |
| 489 | 490 | target, |
| ... | ... | @@ -507,6 +508,10 @@ export function createVcsActionManager<R, E>( |
| 507 | 508 | }), |
| 508 | 509 | }, |
| 509 | 510 | ).pipe( |
| 511 | Effect.provideService(RpcPermissionGuard, { | |
| 512 | authorize: (id, method, payload) => | |
| 513 | createCommandPermissions(runtime, method).authorize(registry, id, payload), | |
| 514 | }), | |
| 510 | 515 | Effect.ensuring(invalidateCachedVcsRefs(registry, target)), |
| 511 | 516 | Effect.tap(() => clearOwnedState), |
| 512 | 517 | Effect.tapError((error) => |
packages/contracts/src/auth.ts+3| ... | ... | @@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const; |
| 84 | 84 | export const AuthProvidersManageScope = "providers:manage" as const; |
| 85 | 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; |
| 86 | 86 | export const AuthTerminalOperateScope = "terminal:operate" as const; |
| 87 | export const AuthSourceControlWriteScope = "source-control:write" as const; | |
| 87 | 88 | export const AuthReviewWriteScope = "review:write" as const; |
| 88 | 89 | export const AuthAccessReadScope = "access:read" as const; |
| 89 | 90 | export const AuthAccessWriteScope = "access:write" as const; |
| ... | ... | @@ -97,6 +98,7 @@ export const AuthEnvironmentScope = Schema.Literals([ |
| 97 | 98 | AuthEnvironmentMaintainScope, |
| 98 | 99 | AuthTerminalOperateScope, |
| 99 | 100 | AuthReviewWriteScope, |
| 101 | AuthSourceControlWriteScope, | |
| 100 | 102 | AuthAccessReadScope, |
| 101 | 103 | AuthAccessWriteScope, |
| 102 | 104 | AuthRelayReadScope, |
| ... | ... | @@ -114,6 +116,7 @@ export const AuthStandardClientScopes = [ |
| 114 | 116 | AuthEnvironmentMaintainScope, |
| 115 | 117 | AuthTerminalOperateScope, |
| 116 | 118 | AuthReviewWriteScope, |
| 119 | AuthSourceControlWriteScope, | |
| 117 | 120 | AuthRelayReadScope, |
| 118 | 121 | ] as const; |
| 119 | 122 | export const AuthAdministrativeScopes = [ |
packages/contracts/src/clientRpcPermissions.ts+40-2| ... | ... | @@ -1,8 +1,39 @@ |
| 1 | import { AuthOrchestrationOperateScope, type AuthEnvironmentScope } from "./auth.ts"; | |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { GitPreparePullRequestThreadInput } from "./git.ts"; | |
| 3 | import { | |
| 4 | AuthOrchestrationOperateScope, | |
| 5 | AuthSourceControlWriteScope, | |
| 6 | type AuthEnvironmentScope, | |
| 7 | } from "./auth.ts"; | |
| 2 | 8 | import { WS_METHODS } from "./rpc.ts"; |
| 3 | 9 | |
| 4 | 10 | /** Incremental client enforcement; the server still authorizes every request. */ |
| 5 | 11 | export const CLIENT_GUARDED_RPC_SCOPES = { |
| 12 | [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, | |
| 13 | [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, | |
| 14 | [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, | |
| 15 | [WS_METHODS.pullRequestsUpdateComment]: AuthSourceControlWriteScope, | |
| 16 | [WS_METHODS.pullRequestsSubmitReview]: AuthSourceControlWriteScope, | |
| 17 | [WS_METHODS.pullRequestsReplyToThread]: AuthSourceControlWriteScope, | |
| 18 | [WS_METHODS.pullRequestsSetThreadResolution]: AuthSourceControlWriteScope, | |
| 19 | [WS_METHODS.pullRequestsSetReaction]: AuthSourceControlWriteScope, | |
| 20 | [WS_METHODS.pullRequestsSetFilesViewed]: AuthSourceControlWriteScope, | |
| 21 | [WS_METHODS.pullRequestsRequestReviewers]: AuthSourceControlWriteScope, | |
| 22 | [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, | |
| 23 | [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, | |
| 24 | [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, | |
| 25 | [WS_METHODS.projectCloneStart]: AuthSourceControlWriteScope, | |
| 26 | [WS_METHODS.projectCloneCancel]: AuthSourceControlWriteScope, | |
| 27 | [WS_METHODS.projectCloneRetry]: AuthSourceControlWriteScope, | |
| 28 | [WS_METHODS.vcsPull]: AuthSourceControlWriteScope, | |
| 29 | [WS_METHODS.gitRunStackedAction]: AuthSourceControlWriteScope, | |
| 30 | [WS_METHODS.gitPreparePullRequestThread]: AuthSourceControlWriteScope, | |
| 31 | [WS_METHODS.vcsCreateWorktree]: AuthSourceControlWriteScope, | |
| 32 | [WS_METHODS.vcsRemoveWorktree]: AuthSourceControlWriteScope, | |
| 33 | [WS_METHODS.vcsCreateRef]: AuthSourceControlWriteScope, | |
| 34 | [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, | |
| 35 | [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, | |
| 36 | ||
| 6 | 37 | [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, |
| 7 | 38 | [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, |
| 8 | 39 | [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, |
| ... | ... | @@ -11,10 +42,17 @@ export const CLIENT_GUARDED_RPC_SCOPES = { |
| 11 | 42 | } as const; |
| 12 | 43 | export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; |
| 13 | 44 | |
| 45 | const decodePrepareThread = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput); | |
| 46 | ||
| 14 | 47 | export function clientRpcRequiredScopes( |
| 15 | 48 | method: string, |
| 16 | _input: unknown, | |
| 49 | input: unknown, | |
| 17 | 50 | ): readonly AuthEnvironmentScope[] { |
| 51 | if (method === WS_METHODS.gitPreparePullRequestThread && input !== undefined) { | |
| 52 | const payload = decodePrepareThread(input); | |
| 53 | if (payload.mode === "worktree" && payload.threadId !== undefined) | |
| 54 | return [AuthSourceControlWriteScope, AuthOrchestrationOperateScope]; | |
| 55 | } | |
| 18 | 56 | return Object.hasOwn(CLIENT_GUARDED_RPC_SCOPES, method) |
| 19 | 57 | ? [CLIENT_GUARDED_RPC_SCOPES[method as ClientGuardedRpcTag]] |
| 20 | 58 | : []; |