| 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,6 +39,8 @@ import { |
| 39 | isWindowsPlatform, | 39 | isWindowsPlatform, |
| 40 | } from "@t3tools/client-runtime/state/projects"; | 40 | } from "@t3tools/client-runtime/state/projects"; |
| 41 | import { | 41 | import { |
| 42 | AuthOrchestrationOperateScope, | ||
| 43 | AuthSourceControlWriteScope, | ||
| 42 | CommandId, | 44 | CommandId, |
| 43 | type EnvironmentId, | 45 | type EnvironmentId, |
| 44 | type EnvironmentMachineKind, | 46 | type EnvironmentMachineKind, |
| ... | @@ -59,6 +61,7 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit | ... | @@ -59,6 +61,7 @@ import { useProjects, useServerConfigs, waitForProject } from "../../state/entit |
| 59 | import { filesystemEnvironment } from "../../state/filesystem"; | 61 | import { filesystemEnvironment } from "../../state/filesystem"; |
| 60 | import { projectEnvironment } from "../../state/projects"; | 62 | import { projectEnvironment } from "../../state/projects"; |
| 61 | import { useEnvironmentQuery } from "../../state/query"; | 63 | import { useEnvironmentQuery } from "../../state/query"; |
| 64 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | ||
| 62 | import { sourceControlEnvironment } from "../../state/sourceControl"; | 65 | import { sourceControlEnvironment } from "../../state/sourceControl"; |
| 63 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 66 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 64 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | 67 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| ... | @@ -522,6 +525,15 @@ export function AddProjectSourceScreen() { | ... | @@ -522,6 +525,15 @@ export function AddProjectSourceScreen() { |
| 522 | const navigation = useNavigation(); | 525 | const navigation = useNavigation(); |
| 523 | const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = | 526 | const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = |
| 524 | useSelectedEnvironment(); | 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 | const discoveryState = useEnvironmentQuery( | 537 | const discoveryState = useEnvironmentQuery( |
| 526 | selectedEnvironment === null | 538 | selectedEnvironment === null |
| 527 | ? null | 539 | ? null |
| ... | @@ -610,7 +622,11 @@ export function AddProjectSourceScreen() { | ... | @@ -610,7 +622,11 @@ export function AddProjectSourceScreen() { |
| 610 | ) : null} | 622 | ) : null} |
| 611 | <ListRow | 623 | <ListRow |
| 612 | title="Local folder" | 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 | icon={ | 630 | icon={ |
| 615 | <SymbolView | 631 | <SymbolView |
| 616 | name="folder.badge.plus" | 632 | name="folder.badge.plus" |
| ... | @@ -620,6 +636,7 @@ export function AddProjectSourceScreen() { | ... | @@ -620,6 +636,7 @@ export function AddProjectSourceScreen() { |
| 620 | /> | 636 | /> |
| 621 | } | 637 | } |
| 622 | isFirst={selectedEnvironment.newProjectsRoot === null} | 638 | isFirst={selectedEnvironment.newProjectsRoot === null} |
| 639 | disabled={!canCreateProject} | ||
| 623 | onPress={() => | 640 | onPress={() => |
| 624 | navigation.dispatch( | 641 | navigation.dispatch( |
| 625 | StackActions.push("AddProjectLocal", { | 642 | StackActions.push("AddProjectLocal", { |
| ... | @@ -634,11 +651,13 @@ export function AddProjectSourceScreen() { | ... | @@ -634,11 +651,13 @@ export function AddProjectSourceScreen() { |
| 634 | key={candidate} | 651 | key={candidate} |
| 635 | source={candidate} | 652 | source={candidate} |
| 636 | selectedEnvironmentId={selectedEnvironment.environmentId} | 653 | selectedEnvironmentId={selectedEnvironment.environmentId} |
| 637 | ready={readiness[candidate].ready} | 654 | ready={canCloneProject && readiness[candidate].ready} |
| 638 | hint={ | 655 | hint={ |
| 639 | readiness[candidate].ready | 656 | !canCloneProject |
| 640 | ? addProjectRemoteSourcePathHint(candidate) | 657 | ? "This connection cannot clone projects." |
| 641 | : (readiness[candidate].hint ?? "") | 658 | : readiness[candidate].ready |
| 659 | ? addProjectRemoteSourcePathHint(candidate) | ||
| 660 | : (readiness[candidate].hint ?? "") | ||
| 642 | } | 661 | } |
| 643 | isFirst={false} | 662 | isFirst={false} |
| 644 | /> | 663 | /> |
| ... | @@ -670,7 +689,17 @@ function useCreateProject(environment: EnvironmentOption | null) { | ... | @@ -670,7 +689,17 @@ function useCreateProject(environment: EnvironmentOption | null) { |
| 670 | 689 | ||
| 671 | return useCallback( | 690 | return useCallback( |
| 672 | async (workspaceRoot: string) => { | 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 | const existing = findExistingAddProject({ | 704 | const existing = findExistingAddProject({ |
| 676 | projects, | 705 | projects, |
| ... | @@ -1159,6 +1188,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s | ... | @@ -1159,6 +1188,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s |
| 1159 | 1188 | ||
| 1160 | export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { | 1189 | export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { |
| 1161 | const environment = useEnvironmentFromParam(props.environmentId); | 1190 | const environment = useEnvironmentFromParam(props.environmentId); |
| 1191 | const canCreateProject = useEnvironmentScope( | ||
| 1192 | environment?.environmentId ?? null, | ||
| 1193 | AuthOrchestrationOperateScope, | ||
| 1194 | ); | ||
| 1162 | const createProject = useCreateProject(environment); | 1195 | const createProject = useCreateProject(environment); |
| 1163 | const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = | 1196 | const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = |
| 1164 | useBrowsePathInput(environment); | 1197 | useBrowsePathInput(environment); |
| ... | @@ -1191,15 +1224,14 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st | ... | @@ -1191,15 +1224,14 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st |
| 1191 | {error ? <ErrorBanner message={error} /> : null} | 1224 | {error ? <ErrorBanner message={error} /> : null} |
| 1192 | {environment ? ( | 1225 | {environment ? ( |
| 1193 | <> | 1226 | <> |
| 1194 | <ProjectPathInput | 1227 | {!canCreateProject ? ( |
| 1195 | value={pathInput} | 1228 | <ErrorBanner message="This connection cannot add projects." /> |
| 1196 | onChangeText={setPathInput} | 1229 | ) : null} |
| 1197 | onSubmit={() => void submitPath()} | 1230 | <ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} /> |
| 1198 | /> | ||
| 1199 | <PrimaryActionButton | 1231 | <PrimaryActionButton |
| 1200 | label="Add project" | 1232 | label="Add project" |
| 1201 | disabled={isBrowseNavigating || isSubmitting} | 1233 | disabled={!canCreateProject || isBrowseNavigating || isSubmitting} |
| 1202 | onPress={() => void submitPath()} | 1234 | onPress={submitPath} |
| 1203 | loading={isSubmitting} | 1235 | loading={isSubmitting} |
| 1204 | /> | 1236 | /> |
| 1205 | <FolderBrowser | 1237 | <FolderBrowser |
| ... | @@ -1230,6 +1262,15 @@ export function AddProjectDestinationScreen(props: { | ... | @@ -1230,6 +1262,15 @@ export function AddProjectDestinationScreen(props: { |
| 1230 | }); | 1262 | }); |
| 1231 | const navigation = useNavigation(); | 1263 | const navigation = useNavigation(); |
| 1232 | const environment = useEnvironmentFromParam(props.environmentId); | 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 | const createProject = useCreateProject(environment); | 1274 | const createProject = useCreateProject(environment); |
| 1234 | const remoteUrl = stringParam(props.remoteUrl); | 1275 | const remoteUrl = stringParam(props.remoteUrl); |
| 1235 | const repositoryTitle = stringParam(props.repositoryTitle); | 1276 | const repositoryTitle = stringParam(props.repositoryTitle); |
| ... | @@ -1246,7 +1287,16 @@ export function AddProjectDestinationScreen(props: { | ... | @@ -1246,7 +1287,16 @@ export function AddProjectDestinationScreen(props: { |
| 1246 | const [error, setError] = useState<string | null>(null); | 1287 | const [error, setError] = useState<string | null>(null); |
| 1247 | 1288 | ||
| 1248 | const submitPath = useCallback(async () => { | 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 | setError(null); | 1300 | setError(null); |
| 1251 | const resolved = resolveAddProjectPath({ | 1301 | const resolved = resolveAddProjectPath({ |
| 1252 | rawPath: pathInput, | 1302 | rawPath: pathInput, |
| ... | @@ -1342,17 +1392,18 @@ export function AddProjectDestinationScreen(props: { | ... | @@ -1342,17 +1392,18 @@ export function AddProjectDestinationScreen(props: { |
| 1342 | ) : null} | 1392 | ) : null} |
| 1343 | {environment ? ( | 1393 | {environment ? ( |
| 1344 | <> | 1394 | <> |
| 1345 | <ProjectPathInput | 1395 | <ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} /> |
| 1346 | value={pathInput} | ||
| 1347 | onChangeText={setPathInput} | ||
| 1348 | onSubmit={() => void submitPath()} | ||
| 1349 | /> | ||
| 1350 | <PrimaryActionButton | 1396 | <PrimaryActionButton |
| 1351 | label="Clone project" | 1397 | label="Clone project" |
| 1352 | disabled={isBrowseNavigating || isSubmitting || !remoteUrl} | 1398 | disabled={!canCloneProject || isBrowseNavigating || isSubmitting || !remoteUrl} |
| 1353 | onPress={() => void submitPath()} | 1399 | onPress={submitPath} |
| 1354 | loading={isSubmitting} | 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 | <FolderBrowser | 1407 | <FolderBrowser |
| 1357 | environment={environment} | 1408 | environment={environment} |
| 1358 | navigateToBrowsePath={navigateToBrowsePath} | 1409 | navigateToBrowsePath={navigateToBrowsePath} |
apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx+14-2| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | import { MaterialListRow } from "../../components/MaterialListRow"; | 1 | import { MaterialListRow } from "../../components/MaterialListRow"; |
| 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 2 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 3 | import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; | ||
| 3 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; | 4 | import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 4 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | 5 | import { AuthSourceControlWriteScope, resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 5 | import { LegendList } from "@legendapp/list/react-native"; | 6 | import { LegendList } from "@legendapp/list/react-native"; |
| 6 | import { | 7 | import { |
| 7 | isAtomCommandInterrupted, | 8 | isAtomCommandInterrupted, |
| ... | @@ -30,6 +31,7 @@ import { ThemedSwitch } from "../../components/ThemedSwitch"; | ... | @@ -30,6 +31,7 @@ import { ThemedSwitch } from "../../components/ThemedSwitch"; |
| 30 | import { cn } from "../../lib/cn"; | 31 | import { cn } from "../../lib/cn"; |
| 31 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 32 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 32 | import { useServerConfigs } from "../../state/entities"; | 33 | import { useServerConfigs } from "../../state/entities"; |
| 34 | import { useEnvironmentScope } from "../../state/session"; | ||
| 33 | import { useAtomCommand } from "../../state/use-atom-command"; | 35 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 34 | import { vcsEnvironment } from "../../state/vcs"; | 36 | import { vcsEnvironment } from "../../state/vcs"; |
| 35 | import { | 37 | import { |
| ... | @@ -264,6 +266,10 @@ export function NewTaskEnvironmentPickerRouteScreen() { | ... | @@ -264,6 +266,10 @@ export function NewTaskEnvironmentPickerRouteScreen() { |
| 264 | 266 | ||
| 265 | export function NewTaskBranchPickerRouteScreen() { | 267 | export function NewTaskBranchPickerRouteScreen() { |
| 266 | const flow = useNewTaskFlow(); | 268 | const flow = useNewTaskFlow(); |
| 269 | const canWriteSourceControl = useEnvironmentScope( | ||
| 270 | flow.selectedProject?.environmentId ?? null, | ||
| 271 | AuthSourceControlWriteScope, | ||
| 272 | ); | ||
| 267 | const navigation = useNavigation(); | 273 | const navigation = useNavigation(); |
| 268 | const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); | 274 | const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); |
| 269 | const [switchingBranchName, setSwitchingBranchName] = useState<string | null>(null); | 275 | const [switchingBranchName, setSwitchingBranchName] = useState<string | null>(null); |
| ... | @@ -291,7 +297,12 @@ export function NewTaskBranchPickerRouteScreen() { | ... | @@ -291,7 +297,12 @@ export function NewTaskBranchPickerRouteScreen() { |
| 291 | 297 | ||
| 292 | const selectBranch = useCallback( | 298 | const selectBranch = useCallback( |
| 293 | async (branch: VcsRef) => { | 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 | return; | 306 | return; |
| 296 | } | 307 | } |
| 297 | selectingBranchNameRef.current = branch.name; | 308 | selectingBranchNameRef.current = branch.name; |
| ... | @@ -336,6 +347,7 @@ export function NewTaskBranchPickerRouteScreen() { | ... | @@ -336,6 +347,7 @@ export function NewTaskBranchPickerRouteScreen() { |
| 336 | } | 347 | } |
| 337 | }, | 348 | }, |
| 338 | [ | 349 | [ |
| 350 | canWriteSourceControl, | ||
| 339 | flow.selectBranch, | 351 | flow.selectBranch, |
| 340 | flow.selectedProject, | 352 | flow.selectedProject, |
| 341 | flow.setBranchQuery, | 353 | flow.setBranchQuery, |
apps/mobile/src/features/threads/ThreadGitControls.tsx+35-13| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios"; | 1 | import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios"; |
| 2 | import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types"; | 2 | import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types"; |
| 3 | import { | 3 | import { |
| 4 | AuthSourceControlWriteScope, | ||
| 4 | EnvironmentId, | 5 | EnvironmentId, |
| 5 | type GitRunStackedActionResult, | 6 | type GitRunStackedActionResult, |
| 6 | type ProjectScript, | 7 | type ProjectScript, |
| ... | @@ -17,6 +18,7 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; | ... | @@ -17,6 +18,7 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 17 | import { useCallback, useMemo } from "react"; | 18 | import { useCallback, useMemo } from "react"; |
| 18 | import { Alert } from "react-native"; | 19 | import { Alert } from "react-native"; |
| 19 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | 20 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| 21 | import { useEnvironmentScope } from "../../state/session"; | ||
| 20 | import { | 22 | import { |
| 21 | basename, | 23 | basename, |
| 22 | getTerminalStatusLabel, | 24 | getTerminalStatusLabel, |
| ... | @@ -113,6 +115,10 @@ type ThreadGitControlsProps = ThreadGitMenuProps & { | ... | @@ -113,6 +115,10 @@ type ThreadGitControlsProps = ThreadGitMenuProps & { |
| 113 | function useThreadGitControlModel(props: ThreadGitMenuProps) { | 115 | function useThreadGitControlModel(props: ThreadGitMenuProps) { |
| 114 | const navigation = useNavigation(); | 116 | const navigation = useNavigation(); |
| 115 | const environmentId = props.environmentId; | 117 | const environmentId = props.environmentId; |
| 118 | const canWriteSourceControl = useEnvironmentScope( | ||
| 119 | environmentId ? EnvironmentId.make(String(environmentId)) : null, | ||
| 120 | AuthSourceControlWriteScope, | ||
| 121 | ); | ||
| 116 | const threadId = props.threadId; | 122 | const threadId = props.threadId; |
| 117 | const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; | 123 | const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; |
| 118 | 124 | ||
| ... | @@ -122,18 +128,24 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { | ... | @@ -122,18 +128,24 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { |
| 122 | const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; | 128 | const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; |
| 123 | const isDefaultRef = gitStatus?.isDefaultRef ?? false; | 129 | const isDefaultRef = gitStatus?.isDefaultRef ?? false; |
| 124 | 130 | ||
| 125 | const quickAction = useMemo( | 131 | const quickAction = useMemo(() => { |
| 126 | () => | 132 | if (!isRepo) { |
| 127 | isRepo | 133 | return { |
| 128 | ? resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote) | 134 | label: "Git unavailable", |
| 129 | : { | 135 | disabled: true, |
| 130 | label: "Git unavailable", | 136 | kind: "show_hint" as const, |
| 131 | disabled: true, | 137 | hint: "This workspace is not a git repository.", |
| 132 | kind: "show_hint" as const, | 138 | }; |
| 133 | hint: "This workspace is not a git repository.", | 139 | } |
| 134 | }, | 140 | const action = resolveQuickAction(gitStatus, busy, isDefaultRef, hasPrimaryRemote); |
| 135 | [busy, gitStatus, hasPrimaryRemote, isDefaultRef, isRepo], | 141 | return !canWriteSourceControl && (action.kind === "run_pull" || action.kind === "run_action") |
| 136 | ); | 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 | const quickActionHint = quickAction.disabled | 150 | const quickActionHint = quickAction.disabled |
| 139 | ? (quickAction.hint ?? "This action is unavailable.") | 151 | ? (quickAction.hint ?? "This action is unavailable.") |
| ... | @@ -163,6 +175,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { | ... | @@ -163,6 +175,7 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { |
| 163 | 175 | ||
| 164 | const runActionWithPrompt = useCallback( | 176 | const runActionWithPrompt = useCallback( |
| 165 | async (input: GitActionRequestInput) => { | 177 | async (input: GitActionRequestInput) => { |
| 178 | if (!canWriteSourceControl) return; | ||
| 166 | const confirmableAction = | 179 | const confirmableAction = |
| 167 | input.action === "push" || | 180 | input.action === "push" || |
| 168 | input.action === "create_pr" || | 181 | input.action === "create_pr" || |
| ... | @@ -191,10 +204,19 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { | ... | @@ -191,10 +204,19 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { |
| 191 | 204 | ||
| 192 | await onRunAction(input); | 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 | const runQuickAction = useCallback(async () => { | 218 | const runQuickAction = useCallback(async () => { |
| 219 | if (quickAction.disabled) return; | ||
| 198 | if (quickAction.kind === "open_pr") { | 220 | if (quickAction.kind === "open_pr") { |
| 199 | await openExistingPr(); | 221 | await openExistingPr(); |
| 200 | return; | 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,6 +30,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 30 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); | 30 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 31 | const gitState = useSelectedThreadGitState(); | 31 | const gitState = useSelectedThreadGitState(); |
| 32 | const gitActions = useSelectedThreadGitActions(); | 32 | const gitActions = useSelectedThreadGitActions(); |
| 33 | const { canChangeThreadBranch } = gitActions; | ||
| 33 | 34 | ||
| 34 | const gitStatus = useEnvironmentQuery( | 35 | const gitStatus = useEnvironmentQuery( |
| 35 | selectedThread !== null && selectedThreadCwd !== null | 36 | selectedThread !== null && selectedThreadCwd !== null |
| ... | @@ -109,11 +110,12 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { | ... | @@ -109,11 +110,12 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 109 | icon="plus" | 110 | icon="plus" |
| 110 | label="Create & checkout" | 111 | label="Create & checkout" |
| 111 | tone="primary" | 112 | tone="primary" |
| 112 | disabled={busy || newBranchName.trim().length === 0} | 113 | disabled={!canChangeThreadBranch || busy || newBranchName.trim().length === 0} |
| 113 | onPress={() => { | 114 | onPress={() => { |
| 114 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); | 115 | const branch = sanitizeFeatureBranchName(newBranchName.trim()); |
| 115 | if (branch.length === 0) return; | 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 | setNewBranchName(""); | 119 | setNewBranchName(""); |
| 118 | navigation.goBack(); | 120 | navigation.goBack(); |
| 119 | }); | 121 | }); |
| ... | @@ -150,6 +152,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { | ... | @@ -150,6 +152,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 150 | label="Create worktree" | 152 | label="Create worktree" |
| 151 | tone="primary" | 153 | tone="primary" |
| 152 | disabled={ | 154 | disabled={ |
| 155 | !canChangeThreadBranch || | ||
| 153 | busy || | 156 | busy || |
| 154 | worktreeBaseBranch.trim().length === 0 || | 157 | worktreeBaseBranch.trim().length === 0 || |
| 155 | worktreeBranchName.trim().length === 0 | 158 | worktreeBranchName.trim().length === 0 |
| ... | @@ -160,7 +163,8 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { | ... | @@ -160,7 +163,8 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 160 | if (baseBranch.length === 0 || newBranch.length === 0) return; | 163 | if (baseBranch.length === 0 || newBranch.length === 0) return; |
| 161 | void gitActions | 164 | void gitActions |
| 162 | .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) | 165 | .onCreateSelectedThreadWorktree({ baseBranch, newBranch }) |
| 163 | .then(() => { | 166 | .then((result) => { |
| 167 | if (result === null) return; | ||
| 164 | setWorktreeBranchName(""); | 168 | setWorktreeBranchName(""); |
| 165 | navigation.goBack(); | 169 | navigation.goBack(); |
| 166 | }); | 170 | }); |
| ... | @@ -203,9 +207,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { | ... | @@ -203,9 +207,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { |
| 203 | )} | 207 | )} |
| 204 | accessibilityRole="button" | 208 | accessibilityRole="button" |
| 205 | accessibilityState={{ selected: branch.current, disabled: busy || disabled }} | 209 | accessibilityState={{ selected: branch.current, disabled: busy || disabled }} |
| 206 | disabled={busy || disabled} | 210 | disabled={!canChangeThreadBranch || busy || disabled} |
| 207 | onPress={() => { | 211 | onPress={() => { |
| 208 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { | 212 | void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => { |
| 213 | if (result === null) return; | ||
| 209 | navigation.goBack(); | 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,6 +30,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 30 | const { selectedThreadCwd } = useSelectedThreadWorktree(); | 30 | const { selectedThreadCwd } = useSelectedThreadWorktree(); |
| 31 | const gitState = useSelectedThreadGitState(); | 31 | const gitState = useSelectedThreadGitState(); |
| 32 | const gitActions = useSelectedThreadGitActions(); | 32 | const gitActions = useSelectedThreadGitActions(); |
| 33 | const { canWriteSourceControl, canChangeThreadBranch } = gitActions; | ||
| 33 | 34 | ||
| 34 | const gitStatus = useEnvironmentQuery( | 35 | const gitStatus = useEnvironmentQuery( |
| 35 | selectedThread !== null && selectedThreadCwd !== null | 36 | selectedThread !== null && selectedThreadCwd !== null |
| ... | @@ -57,6 +58,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { | ... | @@ -57,6 +58,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 57 | 58 | ||
| 58 | const runCommitAction = useCallback( | 59 | const runCommitAction = useCallback( |
| 59 | async (featureBranch: boolean) => { | 60 | async (featureBranch: boolean) => { |
| 61 | if (!canWriteSourceControl || (featureBranch && !canChangeThreadBranch)) return; | ||
| 60 | const commitMessage = dialogCommitMessage.trim(); | 62 | const commitMessage = dialogCommitMessage.trim(); |
| 61 | navigation.goBack(); | 63 | navigation.goBack(); |
| 62 | await gitActions.onRunSelectedThreadGitAction({ | 64 | await gitActions.onRunSelectedThreadGitAction({ |
| ... | @@ -66,7 +68,15 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { | ... | @@ -66,7 +68,15 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 66 | ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), | 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 | return ( | 82 | return ( |
| ... | @@ -274,7 +284,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { | ... | @@ -274,7 +284,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 274 | <SheetActionButton | 284 | <SheetActionButton |
| 275 | icon="arrow.branch" | 285 | icon="arrow.branch" |
| 276 | label="Commit on new branch" | 286 | label="Commit on new branch" |
| 277 | disabled={noneSelected || busy} | 287 | disabled={!canChangeThreadBranch || noneSelected || busy} |
| 278 | onPress={() => void runCommitAction(true)} | 288 | onPress={() => void runCommitAction(true)} |
| 279 | /> | 289 | /> |
| 280 | </View> | 290 | </View> |
| ... | @@ -283,7 +293,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { | ... | @@ -283,7 +293,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { |
| 283 | icon="checkmark.circle" | 293 | icon="checkmark.circle" |
| 284 | label="Commit" | 294 | label="Commit" |
| 285 | tone="primary" | 295 | tone="primary" |
| 286 | disabled={noneSelected || busy} | 296 | disabled={!canWriteSourceControl || noneSelected || busy} |
| 287 | onPress={() => void runCommitAction(false)} | 297 | onPress={() => void runCommitAction(false)} |
| 288 | /> | 298 | /> |
| 289 | </View> | 299 | </View> |
apps/mobile/src/features/threads/git/GitConfirmSheet.tsx+17-4| ... | @@ -32,6 +32,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { | ... | @@ -32,6 +32,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 32 | const { height: windowHeight } = useWindowDimensions(); | 32 | const { height: windowHeight } = useWindowDimensions(); |
| 33 | const gitState = useSelectedThreadGitState(); | 33 | const gitState = useSelectedThreadGitState(); |
| 34 | const gitActions = useSelectedThreadGitActions(); | 34 | const gitActions = useSelectedThreadGitActions(); |
| 35 | const { canWriteSourceControl, canChangeThreadBranch } = gitActions; | ||
| 35 | 36 | ||
| 36 | const params = props.route.params; | 37 | const params = props.route.params; |
| 37 | 38 | ||
| ... | @@ -59,17 +60,25 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { | ... | @@ -59,17 +60,25 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 59 | ); | 60 | ); |
| 60 | 61 | ||
| 61 | const continuePendingAction = useCallback(async () => { | 62 | const continuePendingAction = useCallback(async () => { |
| 62 | if (!confirmAction) return; | 63 | if (!canWriteSourceControl || !confirmAction) return; |
| 63 | navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); | 64 | navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); |
| 64 | await gitActions.onRunSelectedThreadGitAction({ | 65 | await gitActions.onRunSelectedThreadGitAction({ |
| 65 | action: confirmAction, | 66 | action: confirmAction, |
| 66 | ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), | 67 | ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), |
| 67 | ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), | 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 | const movePendingActionToFeatureBranch = useCallback(async () => { | 80 | const movePendingActionToFeatureBranch = useCallback(async () => { |
| 72 | if (!confirmAction) return; | 81 | if (!canChangeThreadBranch || !confirmAction) return; |
| 73 | navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); | 82 | navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); |
| 74 | 83 | ||
| 75 | if (includesCommit) { | 84 | if (includesCommit) { |
| ... | @@ -91,9 +100,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { | ... | @@ -91,9 +100,11 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 91 | branch.isRemote ? Result.failVoid : Result.succeed(branch.name), | 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 | await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); | 105 | await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); |
| 96 | }, [ | 106 | }, [ |
| 107 | canChangeThreadBranch, | ||
| 97 | confirmAction, | 108 | confirmAction, |
| 98 | gitActions, | 109 | gitActions, |
| 99 | gitState.selectedThreadBranches, | 110 | gitState.selectedThreadBranches, |
| ... | @@ -164,11 +175,13 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { | ... | @@ -164,11 +175,13 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { |
| 164 | <SheetActionButton | 175 | <SheetActionButton |
| 165 | icon="arrow.right.circle" | 176 | icon="arrow.right.circle" |
| 166 | label={copy?.continueLabel ?? "Continue"} | 177 | label={copy?.continueLabel ?? "Continue"} |
| 178 | disabled={!canWriteSourceControl} | ||
| 167 | onPress={() => void continuePendingAction()} | 179 | onPress={() => void continuePendingAction()} |
| 168 | /> | 180 | /> |
| 169 | <SheetActionButton | 181 | <SheetActionButton |
| 170 | icon="arrow.branch" | 182 | icon="arrow.branch" |
| 171 | label="Feature branch & continue" | 183 | label="Feature branch & continue" |
| 184 | disabled={!canChangeThreadBranch} | ||
| 172 | tone="primary" | 185 | tone="primary" |
| 173 | onPress={() => void movePendingActionToFeatureBranch()} | 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,6 +87,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 87 | ); | 87 | ); |
| 88 | const gitState = useSelectedThreadGitState(); | 88 | const gitState = useSelectedThreadGitState(); |
| 89 | const gitActions = useSelectedThreadGitActions(); | 89 | const gitActions = useSelectedThreadGitActions(); |
| 90 | const { canWriteSourceControl, canChangeThreadBranch } = gitActions; | ||
| 90 | const theme = useUniwindTheme(); | 91 | const theme = useUniwindTheme(); |
| 91 | const foregroundColor = theme["--color-foreground"]; | 92 | const foregroundColor = theme["--color-foreground"]; |
| 92 | const sheetColor = theme["--color-sheet"]; | 93 | const sheetColor = theme["--color-sheet"]; |
| ... | @@ -117,15 +118,21 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { | ... | @@ -117,15 +118,21 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 117 | const sheetMenuItems = useMemo( | 118 | const sheetMenuItems = useMemo( |
| 118 | () => | 119 | () => |
| 119 | menuItems.map((item) => ({ | 120 | menuItems.map((item) => ({ |
| 120 | item, | 121 | item: { |
| 121 | disabledReason: getGitActionDisabledReason({ | 122 | ...item, |
| 122 | item, | 123 | disabled: item.disabled || (!canWriteSourceControl && item.kind !== "open_pr"), |
| 123 | gitStatus: gitStatus.data, | 124 | }, |
| 124 | isBusy: busy, | 125 | disabledReason: |
| 125 | hasOriginRemote: hasPrimaryRemote, | 126 | !canWriteSourceControl && item.kind !== "open_pr" |
| 126 | }), | 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 | useEffect(() => { | 138 | useEffect(() => { |
| ... | @@ -145,6 +152,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { | ... | @@ -145,6 +152,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 145 | 152 | ||
| 146 | const runActionWithPrompt = useCallback( | 153 | const runActionWithPrompt = useCallback( |
| 147 | async (input: GitActionRequestInput) => { | 154 | async (input: GitActionRequestInput) => { |
| 155 | if (!canWriteSourceControl) return; | ||
| 148 | const confirmableAction = | 156 | const confirmableAction = |
| 149 | input.action === "push" || | 157 | input.action === "push" || |
| 150 | input.action === "create_pr" || | 158 | input.action === "create_pr" || |
| ... | @@ -176,7 +184,16 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { | ... | @@ -176,7 +184,16 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 176 | } | 184 | } |
| 177 | await gitActions.onRunSelectedThreadGitAction(input); | 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 | const onPressMenuItem = useCallback( | 199 | const onPressMenuItem = useCallback( |
| ... | @@ -186,6 +203,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { | ... | @@ -186,6 +203,7 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 186 | await openExistingPr(); | 203 | await openExistingPr(); |
| 187 | return; | 204 | return; |
| 188 | } | 205 | } |
| 206 | if (!canWriteSourceControl) return; | ||
| 189 | if (item.dialogAction === "commit") { | 207 | if (item.dialogAction === "commit") { |
| 190 | navigation.navigate("GitCommit", { | 208 | navigation.navigate("GitCommit", { |
| 191 | environmentId: String(environmentId), | 209 | environmentId: String(environmentId), |
| ... | @@ -201,7 +219,14 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { | ... | @@ -201,7 +219,14 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 201 | await runActionWithPrompt({ action: "create_pr" }); | 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 | // Status facts live on the relevant rows instead of crowding the header | 232 | // Status facts live on the relevant rows instead of crowding the header |
| ... | @@ -284,8 +309,12 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { | ... | @@ -284,8 +309,12 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 284 | <SheetListRow | 309 | <SheetListRow |
| 285 | icon="arrow.down.circle" | 310 | icon="arrow.down.circle" |
| 286 | title="Pull latest" | 311 | title="Pull latest" |
| 287 | subtitle={`${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`} | 312 | subtitle={ |
| 288 | disabled={busy || !isRepo} | 313 | canWriteSourceControl |
| 314 | ? `${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream` | ||
| 315 | : "This connection cannot change source control." | ||
| 316 | } | ||
| 317 | disabled={!canWriteSourceControl || busy || !isRepo} | ||
| 289 | onPress={() => void gitActions.onPullSelectedThreadBranch()} | 318 | onPress={() => void gitActions.onPullSelectedThreadBranch()} |
| 290 | /> | 319 | /> |
| 291 | </> | 320 | </> |
| ... | @@ -309,7 +338,11 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { | ... | @@ -309,7 +338,11 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { |
| 309 | <SheetListRow | 338 | <SheetListRow |
| 310 | icon="point.topleft.down.curvedto.point.bottomright.up" | 339 | icon="point.topleft.down.curvedto.point.bottomright.up" |
| 311 | title="Branches & worktrees" | 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 | disabled={busy || !isRepo} | 346 | disabled={busy || !isRepo} |
| 314 | onPress={() => | 347 | onPress={() => |
| 315 | navigation.navigate("GitBranches", { | 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 +7,12 @@ import { |
| 7 | type VcsActionOperation, | 7 | type VcsActionOperation, |
| 8 | type VcsRef, | 8 | type VcsRef, |
| 9 | } from "@t3tools/client-runtime/state/vcs"; | 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 | import { | 16 | import { |
| 12 | dedupeRemoteBranchesWithLocalMatches, | 17 | dedupeRemoteBranchesWithLocalMatches, |
| 13 | sanitizeFeatureBranchName, | 18 | sanitizeFeatureBranchName, |
| ... | @@ -20,6 +25,7 @@ import { threadEnvironment } from "../state/threads"; | ... | @@ -20,6 +25,7 @@ import { threadEnvironment } from "../state/threads"; |
| 20 | import { vcsActionManager, vcsEnvironment } from "../state/vcs"; | 25 | import { vcsActionManager, vcsEnvironment } from "../state/vcs"; |
| 21 | import { uuidv4 } from "../lib/uuid"; | 26 | import { uuidv4 } from "../lib/uuid"; |
| 22 | import { appAtomRegistry } from "./atom-registry"; | 27 | import { appAtomRegistry } from "./atom-registry"; |
| 28 | import { readEnvironmentScope, useEnvironmentScope } from "./session"; | ||
| 23 | import { setPendingConnectionError } from "./use-remote-environment-registry"; | 29 | import { setPendingConnectionError } from "./use-remote-environment-registry"; |
| 24 | import { useAtomCommand } from "./use-atom-command"; | 30 | import { useAtomCommand } from "./use-atom-command"; |
| 25 | import { showGitActionResult } from "./use-vcs-action-state"; | 31 | import { showGitActionResult } from "./use-vcs-action-state"; |
| ... | @@ -36,6 +42,15 @@ export function useSelectedThreadGitActions() { | ... | @@ -36,6 +42,15 @@ export function useSelectedThreadGitActions() { |
| 36 | const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); | 42 | const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); |
| 37 | const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); | 43 | const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); |
| 38 | const { selectedThread, selectedThreadProject } = useThreadSelection(); | 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 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); | 54 | const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); |
| 40 | const runStackedAction = useAtomCommand( | 55 | const runStackedAction = useAtomCommand( |
| 41 | vcsActionManager.runStackedAction({ | 56 | vcsActionManager.runStackedAction({ |
| ... | @@ -63,6 +78,16 @@ export function useSelectedThreadGitActions() { | ... | @@ -63,6 +78,16 @@ export function useSelectedThreadGitActions() { |
| 63 | readonly worktreePath?: string | null; | 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 | return updateThreadMetadata({ | 91 | return updateThreadMetadata({ |
| 67 | environmentId: thread.environmentId, | 92 | environmentId: thread.environmentId, |
| 68 | input: { | 93 | input: { |
| ... | @@ -131,9 +156,16 @@ export function useSelectedThreadGitActions() { | ... | @@ -131,9 +156,16 @@ export function useSelectedThreadGitActions() { |
| 131 | readonly project: EnvironmentProject; | 156 | readonly project: EnvironmentProject; |
| 132 | readonly cwd: string; | 157 | readonly cwd: string; |
| 133 | }) => Promise<AtomCommandResult<T, E>>, | 158 | }) => Promise<AtomCommandResult<T, E>>, |
| 134 | options?: { readonly managedExternally?: boolean }, | 159 | options?: { readonly managedExternally?: boolean; readonly changesThreadBranch?: boolean }, |
| 135 | ): Promise<T | null> => { | 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 | return null; | 169 | return null; |
| 138 | } | 170 | } |
| 139 | 171 | ||
| ... | @@ -180,22 +212,24 @@ export function useSelectedThreadGitActions() { | ... | @@ -180,22 +212,24 @@ export function useSelectedThreadGitActions() { |
| 180 | readonly worktreePath?: string | null; | 212 | readonly worktreePath?: string | null; |
| 181 | }; | 213 | }; |
| 182 | }): Promise<AtomCommandResult<void, unknown>> => { | 214 | }): Promise<AtomCommandResult<void, unknown>> => { |
| 183 | if (input.nextThreadState) { | 215 | // The Git mutation already landed; refresh what the worktree shows even |
| 184 | const updateResult = await updateThreadGitContext(input.thread, input.nextThreadState); | 216 | // when the thread metadata update is denied, so the sheet does not keep |
| 185 | if (AsyncResult.isFailure(updateResult)) { | 217 | // displaying the previous branch. |
| 186 | return AsyncResult.failure(updateResult.cause); | 218 | const updateResult = input.nextThreadState |
| 187 | } | 219 | ? await updateThreadGitContext(input.thread, input.nextThreadState) |
| 188 | } | 220 | : AsyncResult.success(undefined); |
| 189 | branchState.refresh(); | 221 | branchState.refresh(); |
| 190 | await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); | 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 | [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext], | 227 | [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext], |
| 194 | ); | 228 | ); |
| 195 | 229 | ||
| 196 | const onCheckoutSelectedThreadBranch = useCallback( | 230 | const onCheckoutSelectedThreadBranch = useCallback( |
| 197 | async (branch: string) => { | 231 | async (branch: string) => { |
| 198 | await runSelectedThreadGitMutation( | 232 | return runSelectedThreadGitMutation( |
| 199 | "switch_ref", | 233 | "switch_ref", |
| 200 | "Switching branch", | 234 | "Switching branch", |
| 201 | async ({ thread, cwd }) => { | 235 | async ({ thread, cwd }) => { |
| ... | @@ -216,6 +250,7 @@ export function useSelectedThreadGitActions() { | ... | @@ -216,6 +250,7 @@ export function useSelectedThreadGitActions() { |
| 216 | }); | 250 | }); |
| 217 | return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; | 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,7 +263,7 @@ export function useSelectedThreadGitActions() { |
| 228 | 263 | ||
| 229 | const onCreateSelectedThreadBranch = useCallback( | 264 | const onCreateSelectedThreadBranch = useCallback( |
| 230 | async (branch: string) => { | 265 | async (branch: string) => { |
| 231 | await runSelectedThreadGitMutation( | 266 | return runSelectedThreadGitMutation( |
| 232 | "create_ref", | 267 | "create_ref", |
| 233 | "Creating branch", | 268 | "Creating branch", |
| 234 | async ({ thread, cwd }) => { | 269 | async ({ thread, cwd }) => { |
| ... | @@ -249,6 +284,7 @@ export function useSelectedThreadGitActions() { | ... | @@ -249,6 +284,7 @@ export function useSelectedThreadGitActions() { |
| 249 | }); | 284 | }); |
| 250 | return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; | 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,7 +297,7 @@ export function useSelectedThreadGitActions() { |
| 261 | 297 | ||
| 262 | const onCreateSelectedThreadWorktree = useCallback( | 298 | const onCreateSelectedThreadWorktree = useCallback( |
| 263 | async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { | 299 | async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { |
| 264 | await runSelectedThreadGitMutation( | 300 | return runSelectedThreadGitMutation( |
| 265 | "create_worktree", | 301 | "create_worktree", |
| 266 | "Creating worktree", | 302 | "Creating worktree", |
| 267 | async ({ thread, project }) => { | 303 | async ({ thread, project }) => { |
| ... | @@ -287,6 +323,7 @@ export function useSelectedThreadGitActions() { | ... | @@ -287,6 +323,7 @@ export function useSelectedThreadGitActions() { |
| 287 | }); | 323 | }); |
| 288 | return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; | 324 | return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; |
| 289 | }, | 325 | }, |
| 326 | { changesThreadBranch: true }, | ||
| 290 | ); | 327 | ); |
| 291 | }, | 328 | }, |
| 292 | [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], | 329 | [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], |
| ... | @@ -337,14 +374,6 @@ export function useSelectedThreadGitActions() { | ... | @@ -337,14 +374,6 @@ export function useSelectedThreadGitActions() { |
| 337 | return result; | 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 | if (result.value.branch.status === "created" && result.value.branch.name) { | 377 | if (result.value.branch.status === "created" && result.value.branch.name) { |
| 349 | const syncResult = await syncSelectedThreadBranchState({ | 378 | const syncResult = await syncSelectedThreadBranchState({ |
| 350 | thread, | 379 | thread, |
| ... | @@ -360,9 +389,16 @@ export function useSelectedThreadGitActions() { | ... | @@ -360,9 +389,16 @@ export function useSelectedThreadGitActions() { |
| 360 | } else { | 389 | } else { |
| 361 | await refreshSelectedThreadGitStatus({ quiet: true, cwd }); | 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 | return result; | 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,6 +411,8 @@ export function useSelectedThreadGitActions() { |
| 375 | ); | 411 | ); |
| 376 | 412 | ||
| 377 | return { | 413 | return { |
| 414 | canWriteSourceControl, | ||
| 415 | canChangeThreadBranch, | ||
| 378 | refreshSelectedThreadGitStatus, | 416 | refreshSelectedThreadGitStatus, |
| 379 | refreshSelectedThreadBranches, | 417 | refreshSelectedThreadBranches, |
| 380 | onCheckoutSelectedThreadBranch, | 418 | onCheckoutSelectedThreadBranch, |
apps/server/src/auth/RpcAuthorization.test.ts+61| ... | @@ -4,8 +4,10 @@ import { | ... | @@ -4,8 +4,10 @@ import { |
| 4 | AuthSettingsWriteScope, | 4 | AuthSettingsWriteScope, |
| 5 | DEFAULT_SERVER_SETTINGS, | 5 | DEFAULT_SERVER_SETTINGS, |
| 6 | ProviderInstanceId, | 6 | ProviderInstanceId, |
| 7 | ThreadId, | ||
| 7 | AuthOrchestrationOperateScope, | 8 | AuthOrchestrationOperateScope, |
| 8 | AuthOrchestrationReadScope, | 9 | AuthOrchestrationReadScope, |
| 10 | AuthSourceControlWriteScope, | ||
| 9 | AuthRelayReadScope, | 11 | AuthRelayReadScope, |
| 10 | AuthRelayWriteScope, | 12 | AuthRelayWriteScope, |
| 11 | WS_METHODS, | 13 | WS_METHODS, |
| ... | @@ -113,6 +115,28 @@ describe("RPC authorization scopes", () => { | ... | @@ -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 | it("rejects unknown RPC method names", () => { | 140 | it("rejects unknown RPC method names", () => { |
| 117 | for (const method of ["server.notRegistered", "toString", "constructor"]) { | 141 | for (const method of ["server.notRegistered", "toString", "constructor"]) { |
| 118 | expect(() => requiredScopeForRpcMethod(method)).toThrow( | 142 | expect(() => requiredScopeForRpcMethod(method)).toThrow( |
| ... | @@ -240,3 +264,40 @@ describe("settings mutation authorization", () => { | ... | @@ -240,3 +264,40 @@ describe("settings mutation authorization", () => { |
| 240 | }).pipe(Effect.scoped), | 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,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | CLIENT_GUARDED_RPC_SCOPES, | 2 | CLIENT_GUARDED_RPC_SCOPES, |
| 3 | type DeviceListInput, | 3 | type DeviceListInput, |
| 4 | clientRpcRequiredScopes, | ||
| 4 | AuthAccessReadScope, | 5 | AuthAccessReadScope, |
| 5 | ServerSettingsPatch, | 6 | ServerSettingsPatch, |
| 6 | ProviderInstanceMutation, | 7 | ProviderInstanceMutation, |
| ... | @@ -120,15 +121,6 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -120,15 +121,6 @@ export const RPC_REQUIRED_SCOPES = { |
| 120 | [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, | 121 | [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, |
| 121 | [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, | 122 | [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, |
| 122 | [WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope, | 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 | // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only | 124 | // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only |
| 133 | // client pressing refresh must not be told it may not look again. | 125 | // client pressing refresh must not be told it may not look again. |
| 134 | [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, | 126 | [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, |
| ... | @@ -136,15 +128,8 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -136,15 +128,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 136 | // The candidate list is a read like the detail beside it; asking somebody for a review is a | 128 | // The candidate list is a read like the detail beside it; asking somebody for a review is a |
| 137 | // write like every other one. | 129 | // write like every other one. |
| 138 | [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, | 130 | [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, |
| 139 | [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, | ||
| 140 | [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, | 131 | [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, |
| 141 | [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, | ||
| 142 | [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, | 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 | [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, | 133 | [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, |
| 149 | [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, | 134 | [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, |
| 150 | [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, | 135 | [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, |
| ... | @@ -167,16 +152,8 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -167,16 +152,8 @@ export const RPC_REQUIRED_SCOPES = { |
| 167 | [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, | 152 | [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, |
| 168 | [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, | 153 | [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, |
| 169 | [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, | 154 | [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, |
| 170 | [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, | 155 | [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, |
| 171 | [WS_METHODS.gitRunStackedAction]: AuthOrchestrationOperateScope, | ||
| 172 | [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, | ||
| 173 | [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, | ||
| 174 | [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, | 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 | [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, | 157 | [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope, |
| 181 | [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, | 158 | [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope, |
| 182 | [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, | 159 | [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, |
| ... | @@ -246,13 +223,20 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => { | ... | @@ -246,13 +223,20 @@ const requiredScopesForSettingsUpdate = (payload: unknown) => { |
| 246 | : [...new Set([...scopes, AuthProvidersManageScope])]; | 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 | /** Authorizes every RPC on one connection against that connection's session scopes. */ | 236 | /** Authorizes every RPC on one connection against that connection's session scopes. */ |
| 250 | export const layer = (scopes: ReadonlyArray<AuthEnvironmentScope>) => | 237 | export const layer = (scopes: ReadonlyArray<AuthEnvironmentScope>) => |
| 251 | Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => { | 238 | Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => { |
| 252 | const requiredScopes = | 239 | const requiredScopes = requiredScopesForRpcCall(rpc._tag, payload); |
| 253 | rpc._tag === WS_METHODS.serverUpdateSettings | ||
| 254 | ? requiredScopesForSettingsUpdate(payload) | ||
| 255 | : [requiredScopeForRpcMethod(rpc._tag)]; | ||
| 256 | const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope)); | 240 | const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope)); |
| 257 | return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope)); | 241 | return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope)); |
| 258 | }); | 242 | }); |
apps/server/src/auth/http.ts+2| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | AuthRelayReadScope, | 10 | AuthRelayReadScope, |
| 11 | AuthRelayWriteScope, | 11 | AuthRelayWriteScope, |
| 12 | AuthReviewWriteScope, | 12 | AuthReviewWriteScope, |
| 13 | AuthSourceControlWriteScope, | ||
| 13 | AuthTerminalOperateScope, | 14 | AuthTerminalOperateScope, |
| 14 | EnvironmentAuthInvalidError, | 15 | EnvironmentAuthInvalidError, |
| 15 | type EnvironmentAuthInvalidReason, | 16 | type EnvironmentAuthInvalidReason, |
| ... | @@ -374,6 +375,7 @@ export const layer = HttpApiBuilder.group( | ... | @@ -374,6 +375,7 @@ export const layer = HttpApiBuilder.group( |
| 374 | AuthEnvironmentMaintainScope, | 375 | AuthEnvironmentMaintainScope, |
| 375 | AuthTerminalOperateScope, | 376 | AuthTerminalOperateScope, |
| 376 | AuthReviewWriteScope, | 377 | AuthReviewWriteScope, |
| 378 | AuthSourceControlWriteScope, | ||
| 377 | AuthAccessReadScope, | 379 | AuthAccessReadScope, |
| 378 | AuthAccessWriteScope, | 380 | AuthAccessWriteScope, |
| 379 | AuthRelayReadScope, | 381 | AuthRelayReadScope, |
apps/web/src/components/BranchPicker.tsx+3| ... | @@ -264,6 +264,7 @@ export function BranchPickerRefItem({ | ... | @@ -264,6 +264,7 @@ export function BranchPickerRefItem({ |
| 264 | projectCwd: activeProjectCwd, | 264 | projectCwd: activeProjectCwd, |
| 265 | index, | 265 | index, |
| 266 | value, | 266 | value, |
| 267 | disabled, | ||
| 267 | onClick, | 268 | onClick, |
| 268 | onContextMenu, | 269 | onContextMenu, |
| 269 | }: { | 270 | }: { |
| ... | @@ -271,6 +272,7 @@ export function BranchPickerRefItem({ | ... | @@ -271,6 +272,7 @@ export function BranchPickerRefItem({ |
| 271 | projectCwd: string | null; | 272 | projectCwd: string | null; |
| 272 | index: number; | 273 | index: number; |
| 273 | value?: string; | 274 | value?: string; |
| 275 | disabled?: boolean; | ||
| 274 | onClick: ComponentProps<typeof ComboboxItem>["onClick"]; | 276 | onClick: ComponentProps<typeof ComboboxItem>["onClick"]; |
| 275 | onContextMenu?: ComponentProps<typeof ComboboxItem>["onContextMenu"]; | 277 | onContextMenu?: ComponentProps<typeof ComboboxItem>["onContextMenu"]; |
| 276 | }) { | 278 | }) { |
| ... | @@ -288,6 +290,7 @@ export function BranchPickerRefItem({ | ... | @@ -288,6 +290,7 @@ export function BranchPickerRefItem({ |
| 288 | : null; | 290 | : null; |
| 289 | return ( | 291 | return ( |
| 290 | <ComboboxItem | 292 | <ComboboxItem |
| 293 | disabled={disabled} | ||
| 291 | hideIndicator | 294 | hideIndicator |
| 292 | key={itemValue} | 295 | key={itemValue} |
| 293 | index={index} | 296 | index={index} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+51-5| ... | @@ -8,7 +8,14 @@ import { | ... | @@ -8,7 +8,14 @@ import { |
| 8 | isAtomCommandInterrupted, | 8 | isAtomCommandInterrupted, |
| 9 | squashAtomCommandFailure, | 9 | squashAtomCommandFailure, |
| 10 | } from "@t3tools/client-runtime/state/runtime"; | 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 | import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; | 19 | import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; |
| 13 | import { | 20 | import { |
| 14 | useCallback, | 21 | useCallback, |
| ... | @@ -30,6 +37,7 @@ import { useOpenPrLink } from "../lib/openPullRequestLink"; | ... | @@ -30,6 +37,7 @@ import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 30 | import { usePaginatedBranches } from "../state/queries"; | 37 | import { usePaginatedBranches } from "../state/queries"; |
| 31 | import { useProject, useThreadShell } from "../state/entities"; | 38 | import { useProject, useThreadShell } from "../state/entities"; |
| 32 | import { useEnvironmentQuery } from "../state/query"; | 39 | import { useEnvironmentQuery } from "../state/query"; |
| 40 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | ||
| 33 | import { threadEnvironment } from "../state/threads"; | 41 | import { threadEnvironment } from "../state/threads"; |
| 34 | import { useAtomCommand } from "../state/use-atom-command"; | 42 | import { useAtomCommand } from "../state/use-atom-command"; |
| 35 | import { vcsEnvironment } from "../state/vcs"; | 43 | import { vcsEnvironment } from "../state/vcs"; |
| ... | @@ -110,6 +118,8 @@ export function BranchToolbarBranchSelector({ | ... | @@ -110,6 +118,8 @@ export function BranchToolbarBranchSelector({ |
| 110 | onComposerFocusRequest, | 118 | onComposerFocusRequest, |
| 111 | }: BranchToolbarBranchSelectorProps) { | 119 | }: BranchToolbarBranchSelectorProps) { |
| 112 | const composerFloatingLayerProps = useComposerMenuProps(); | 120 | const composerFloatingLayerProps = useComposerMenuProps(); |
| 121 | const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); | ||
| 122 | const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | ||
| 113 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); | 123 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); |
| 114 | const updateThreadMetadata = useAtomCommand( | 124 | const updateThreadMetadata = useAtomCommand( |
| 115 | threadEnvironment.updateMetadata, | 125 | threadEnvironment.updateMetadata, |
| ... | @@ -153,6 +163,8 @@ export function BranchToolbarBranchSelector({ | ... | @@ -153,6 +163,8 @@ export function BranchToolbarBranchSelector({ |
| 153 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; | 163 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; |
| 154 | const branchCwd = activeWorktreePath ?? activeProjectCwd; | 164 | const branchCwd = activeWorktreePath ?? activeProjectCwd; |
| 155 | const hasServerThread = serverThread !== null; | 165 | const hasServerThread = serverThread !== null; |
| 166 | const canUpdateThreadBranch = !hasServerThread || canOperateThread; | ||
| 167 | const canChangeThreadBranch = canWriteSourceControl && canUpdateThreadBranch; | ||
| 156 | const effectiveEnvMode = | 168 | const effectiveEnvMode = |
| 157 | effectiveEnvModeOverride ?? | 169 | effectiveEnvModeOverride ?? |
| 158 | resolveEffectiveEnvMode({ | 170 | resolveEffectiveEnvMode({ |
| ... | @@ -166,7 +178,12 @@ export function BranchToolbarBranchSelector({ | ... | @@ -166,7 +178,12 @@ export function BranchToolbarBranchSelector({ |
| 166 | // --------------------------------------------------------------------------- | 178 | // --------------------------------------------------------------------------- |
| 167 | const setThreadBranch = useCallback( | 179 | const setThreadBranch = useCallback( |
| 168 | (branch: string | null, worktreePath: string | null, automatic = false) => { | 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 | if (serverSession && worktreePath !== activeWorktreePath) { | 187 | if (serverSession && worktreePath !== activeWorktreePath) { |
| 171 | void stopThreadSession({ | 188 | void stopThreadSession({ |
| 172 | environmentId, | 189 | environmentId, |
| ... | @@ -273,8 +290,11 @@ export function BranchToolbarBranchSelector({ | ... | @@ -273,8 +290,11 @@ export function BranchToolbarBranchSelector({ |
| 273 | const isSelectingWorktreeBase = | 290 | const isSelectingWorktreeBase = |
| 274 | effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; | 291 | effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; |
| 275 | const checkoutPullRequestItemValue = | 292 | const checkoutPullRequestItemValue = |
| 276 | prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; | 293 | canChangeThreadBranch && prReference && onCheckoutPullRequestRequest |
| 277 | const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; | 294 | ? `__checkout_pull_request__:${prReference}` |
| 295 | : null; | ||
| 296 | const canCreateBranch = | ||
| 297 | canChangeThreadBranch && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; | ||
| 278 | // The ref is created under its sanitized name, so the collision check has to | 298 | // The ref is created under its sanitized name, so the collision check has to |
| 279 | // use that name too. Matching on the raw query would offer to create a ref | 299 | // use that name too. Matching on the raw query would offer to create a ref |
| 280 | // that already exists whenever sanitizing changes the name. | 300 | // that already exists whenever sanitizing changes the name. |
| ... | @@ -392,6 +412,20 @@ export function BranchToolbarBranchSelector({ | ... | @@ -392,6 +412,20 @@ export function BranchToolbarBranchSelector({ |
| 392 | ); | 412 | ); |
| 393 | 413 | ||
| 394 | const runBranchAction = (action: () => Promise<void>) => { | 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 | startBranchActionTransition(async () => { | 429 | startBranchActionTransition(async () => { |
| 396 | await action(); | 430 | await action(); |
| 397 | branchRefState.refresh(); | 431 | branchRefState.refresh(); |
| ... | @@ -400,7 +434,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -400,7 +434,7 @@ export function BranchToolbarBranchSelector({ |
| 400 | }; | 434 | }; |
| 401 | 435 | ||
| 402 | const selectBranch = (refName: VcsRef) => { | 436 | const selectBranch = (refName: VcsRef) => { |
| 403 | if (!branchCwd || !activeProjectCwd || isBranchActionPending) return; | 437 | if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; |
| 404 | 438 | ||
| 405 | if (isSelectingWorktreeBase) { | 439 | if (isSelectingWorktreeBase) { |
| 406 | setThreadBranch(refName.name, null); | 440 | setThreadBranch(refName.name, null); |
| ... | @@ -461,6 +495,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -461,6 +495,7 @@ export function BranchToolbarBranchSelector({ |
| 461 | }; | 495 | }; |
| 462 | 496 | ||
| 463 | const createRef = (rawName: string) => { | 497 | const createRef = (rawName: string) => { |
| 498 | if (!canChangeThreadBranch) return; | ||
| 464 | const name = sanitizeNewRefName(rawName); | 499 | const name = sanitizeNewRefName(rawName); |
| 465 | if (!branchCwd || !name || isBranchActionPending) return; | 500 | if (!branchCwd || !name || isBranchActionPending) return; |
| 466 | 501 | ||
| ... | @@ -647,6 +682,17 @@ export function BranchToolbarBranchSelector({ | ... | @@ -647,6 +682,17 @@ export function BranchToolbarBranchSelector({ |
| 647 | projectCwd={activeProjectCwd} | 682 | projectCwd={activeProjectCwd} |
| 648 | index={index} | 683 | index={index} |
| 649 | value={itemValue} | 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 | onClick={() => selectPickerItem(itemValue)} | 696 | onClick={() => selectPickerItem(itemValue)} |
| 651 | onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} | 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,6 +50,7 @@ import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue"; |
| 50 | import { | 50 | import { |
| 51 | AuthOrchestrationOperateScope, | 51 | AuthOrchestrationOperateScope, |
| 52 | AuthSettingsWriteScope, | 52 | AuthSettingsWriteScope, |
| 53 | AuthSourceControlWriteScope, | ||
| 53 | type AssistantCitation, | 54 | type AssistantCitation, |
| 54 | type ChatFileAttachment, | 55 | type ChatFileAttachment, |
| 55 | CommandId, | 56 | CommandId, |
| ... | @@ -397,6 +398,7 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect | ... | @@ -397,6 +398,7 @@ import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnect |
| 397 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 398 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 398 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; | 399 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 399 | import { useEnvironmentQuery } from "../state/query"; | 400 | import { useEnvironmentQuery } from "../state/query"; |
| 401 | import { useEnvironmentScope } from "~/state/session"; | ||
| 400 | import { | 402 | import { |
| 401 | environmentServerConfigsAtom, | 403 | environmentServerConfigsAtom, |
| 402 | primaryServerAvailableEditorsAtom, | 404 | primaryServerAvailableEditorsAtom, |
| ... | @@ -559,11 +561,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at | ... | @@ -559,11 +561,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at |
| 559 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 561 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 560 | import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; | 562 | import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; |
| 561 | import { assetEnvironment } from "../state/assets"; | 563 | import { assetEnvironment } from "../state/assets"; |
| 562 | import { | 564 | import { readEnvironmentScope, readPreparedConnection } from "../state/session"; |
| 563 | readEnvironmentScope, | ||
| 564 | readPreparedConnection, | ||
| 565 | useEnvironmentScope, | ||
| 566 | } from "../state/session"; | ||
| 567 | import { useAtomCommand } from "../state/use-atom-command"; | 565 | import { useAtomCommand } from "../state/use-atom-command"; |
| 568 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; | 566 | import { useOrchestrationCommand } from "../state/use-orchestration-command"; |
| 569 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | 567 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| ... | @@ -1577,6 +1575,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1577,6 +1575,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1577 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { | 1575 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { |
| 1578 | reportFailure: false, | 1576 | reportFailure: false, |
| 1579 | }); | 1577 | }); |
| 1578 | const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); | ||
| 1580 | const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); | 1579 | const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); |
| 1581 | const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { | 1580 | const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { |
| 1582 | reportFailure: false, | 1581 | reportFailure: false, |
| ... | @@ -2164,7 +2163,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2164,7 +2163,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2164 | (isServerThread ? activeThread?.runtimeMode : undefined) ?? | 2163 | (isServerThread ? activeThread?.runtimeMode : undefined) ?? |
| 2165 | defaultRuntimeMode; | 2164 | defaultRuntimeMode; |
| 2166 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; | 2165 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 2167 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; | 2166 | const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread; |
| 2168 | const activeThreadId = activeThread?.id ?? null; | 2167 | const activeThreadId = activeThread?.id ?? null; |
| 2169 | // Prefer the larger of turn-item-committed ids and projection messages so | 2168 | // Prefer the larger of turn-item-committed ids and projection messages so |
| 2170 | // env lock does not unlock while turn items lag projection hydration. | 2169 | // env lock does not unlock while turn items lag projection hydration. |
| ... | @@ -7161,6 +7160,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7161,6 +7160,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7161 | }); | 7160 | }); |
| 7162 | }, [activeBranchMismatchKey, showBranchMismatchBanner]); | 7161 | }, [activeBranchMismatchKey, showBranchMismatchBanner]); |
| 7163 | const handleSwitchCheckoutToThread = useCallback(async () => { | 7162 | const handleSwitchCheckoutToThread = useCallback(async () => { |
| 7163 | if (!canWriteSourceControl) return; | ||
| 7164 | if ( | 7164 | if ( |
| 7165 | !activeProjectCwd || | 7165 | !activeProjectCwd || |
| 7166 | !activeThread || | 7166 | !activeThread || |
| ... | @@ -7191,6 +7191,10 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7191,6 +7191,10 @@ export default function ChatView(props: ChatViewProps) { |
| 7191 | return; | 7191 | return; |
| 7192 | } | 7192 | } |
| 7193 | 7193 | ||
| 7194 | if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) { | ||
| 7195 | setIsRestoringThreadBranch(false); | ||
| 7196 | return; | ||
| 7197 | } | ||
| 7194 | const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; | 7198 | const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; |
| 7195 | if (nextBranch !== activeThread.branch) { | 7199 | if (nextBranch !== activeThread.branch) { |
| 7196 | const updateResult = await updateThreadMetadata({ | 7200 | const updateResult = await updateThreadMetadata({ |
| ... | @@ -7216,6 +7220,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7216,6 +7220,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7216 | setIsRestoringThreadBranch(false); | 7220 | setIsRestoringThreadBranch(false); |
| 7217 | scheduleComposerFocus(); | 7221 | scheduleComposerFocus(); |
| 7218 | }, [ | 7222 | }, [ |
| 7223 | canWriteSourceControl, | ||
| 7219 | activeProjectCwd, | 7224 | activeProjectCwd, |
| 7220 | activeThread, | 7225 | activeThread, |
| 7221 | environmentId, | 7226 | environmentId, |
| ... | @@ -7622,12 +7627,17 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7622,12 +7627,17 @@ export default function ChatView(props: ChatViewProps) { |
| 7622 | selectedProvider, | 7627 | selectedProvider, |
| 7623 | ]); | 7628 | ]); |
| 7624 | const handleRestoreThreadBranch = useCallback(() => { | 7629 | const handleRestoreThreadBranch = useCallback(() => { |
| 7630 | if (!canWriteSourceControl) return; | ||
| 7625 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { | 7631 | if (gitStatusQuery.data?.hasWorkingTreeChanges) { |
| 7626 | setBranchRestoreConfirmOpen(true); | 7632 | setBranchRestoreConfirmOpen(true); |
| 7627 | return; | 7633 | return; |
| 7628 | } | 7634 | } |
| 7629 | void handleSwitchCheckoutToThread(); | 7635 | void handleSwitchCheckoutToThread(); |
| 7630 | }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); | 7636 | }, [ |
| 7637 | canWriteSourceControl, | ||
| 7638 | gitStatusQuery.data?.hasWorkingTreeChanges, | ||
| 7639 | handleSwitchCheckoutToThread, | ||
| 7640 | ]); | ||
| 7631 | const feedbackBannerItems = useMemo( | 7641 | const feedbackBannerItems = useMemo( |
| 7632 | () => | 7642 | () => |
| 7633 | feedbackSubmissions.flatMap((submission) => { | 7643 | feedbackSubmissions.flatMap((submission) => { |
| ... | @@ -7722,7 +7732,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7722,7 +7732,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7722 | <Button | 7732 | <Button |
| 7723 | size="xs" | 7733 | size="xs" |
| 7724 | variant="ghost" | 7734 | variant="ghost" |
| 7725 | disabled={isRestoringThreadBranch} | 7735 | disabled={!canWriteSourceControl || isRestoringThreadBranch} |
| 7726 | onClick={handleRestoreThreadBranch} | 7736 | onClick={handleRestoreThreadBranch} |
| 7727 | > | 7737 | > |
| 7728 | {isRestoringThreadBranch ? "Restoring..." : "Restore branch"} | 7738 | {isRestoringThreadBranch ? "Restoring..." : "Restore branch"} |
| ... | @@ -7740,6 +7750,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7740,6 +7750,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7740 | activeBranchMismatchKey, | 7750 | activeBranchMismatchKey, |
| 7741 | activeThreadShell, | 7751 | activeThreadShell, |
| 7742 | serverRuntime?.usageLimitResetAt, | 7752 | serverRuntime?.usageLimitResetAt, |
| 7753 | canWriteSourceControl, | ||
| 7743 | feedbackBannerItems, | 7754 | feedbackBannerItems, |
| 7744 | limitRecoveryBanner, | 7755 | limitRecoveryBanner, |
| 7745 | handleRestoreThreadBranch, | 7756 | handleRestoreThreadBranch, |
| ... | @@ -11654,6 +11665,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -11654,6 +11665,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11654 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> | 11665 | <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> |
| 11655 | <Button | 11666 | <Button |
| 11656 | variant="default" | 11667 | variant="default" |
| 11668 | disabled={!canWriteSourceControl} | ||
| 11657 | onClick={() => { | 11669 | onClick={() => { |
| 11658 | setBranchRestoreConfirmOpen(false); | 11670 | setBranchRestoreConfirmOpen(false); |
| 11659 | void handleSwitchCheckoutToThread(); | 11671 | void handleSwitchCheckoutToThread(); |
apps/web/src/components/CommandPalette.tsx+45-4| ... | @@ -30,6 +30,8 @@ import { | ... | @@ -30,6 +30,8 @@ import { |
| 30 | squashAtomCommandFailure, | 30 | squashAtomCommandFailure, |
| 31 | } from "@t3tools/client-runtime/state/runtime"; | 31 | } from "@t3tools/client-runtime/state/runtime"; |
| 32 | import { | 32 | import { |
| 33 | AuthOrchestrationOperateScope, | ||
| 34 | AuthSourceControlWriteScope, | ||
| 33 | type DesktopWslState, | 35 | type DesktopWslState, |
| 34 | type EnvironmentId, | 36 | type EnvironmentId, |
| 35 | type EnvironmentMachineKind, | 37 | type EnvironmentMachineKind, |
| ... | @@ -103,6 +105,7 @@ import { projectEnvironment } from "../state/projects"; | ... | @@ -103,6 +105,7 @@ import { projectEnvironment } from "../state/projects"; |
| 103 | import { useEnvironmentQuery } from "../state/query"; | 105 | import { useEnvironmentQuery } from "../state/query"; |
| 104 | import { serverEnvironment } from "../state/server"; | 106 | import { serverEnvironment } from "../state/server"; |
| 105 | import { threadEnvironment } from "../state/threads"; | 107 | import { threadEnvironment } from "../state/threads"; |
| 108 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | ||
| 106 | import { sourceControlEnvironment } from "../state/sourceControl"; | 109 | import { sourceControlEnvironment } from "../state/sourceControl"; |
| 107 | import { useAtomCommand } from "../state/use-atom-command"; | 110 | import { useAtomCommand } from "../state/use-atom-command"; |
| 108 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | 111 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| ... | @@ -1039,6 +1042,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1039,6 +1042,7 @@ function OpenCommandPaletteDialog(props: { |
| 1039 | [addProjectEnvironmentOptions, environments], | 1042 | [addProjectEnvironmentOptions, environments], |
| 1040 | ); | 1043 | ); |
| 1041 | const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; | 1044 | const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; |
| 1045 | const canCreateProject = useEnvironmentScope(browseEnvironmentId, AuthOrchestrationOperateScope); | ||
| 1042 | const browseEnvironment = | 1046 | const browseEnvironment = |
| 1043 | environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null; | 1047 | environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null; |
| 1044 | // A desktop-local secondary backend (today: the WSL backend). The picker is | 1048 | // A desktop-local secondary backend (today: the WSL backend). The picker is |
| ... | @@ -2418,6 +2422,16 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2418,6 +2422,16 @@ function OpenCommandPaletteDialog(props: { |
| 2418 | return; | 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 | const projectId = newProjectId(); | 2435 | const projectId = newProjectId(); |
| 2422 | const createResult = await createProject({ | 2436 | const createResult = await createProject({ |
| 2423 | environmentId: input.environmentId, | 2437 | environmentId: input.environmentId, |
| ... | @@ -2607,9 +2621,20 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2607,9 +2621,20 @@ function OpenCommandPaletteDialog(props: { |
| 2607 | } | 2621 | } |
| 2608 | 2622 | ||
| 2609 | const rawDestination = (destinationPathInput ?? query).trim(); | 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 | return; | 2635 | return; |
| 2612 | } | 2636 | } |
| 2637 | if (rawDestination.length === 0 || isRemoteProjectCloning) return; | ||
| 2613 | 2638 | ||
| 2614 | if (isUnsupportedWindowsProjectPath(rawDestination, browseEnvironmentPlatform)) { | 2639 | if (isUnsupportedWindowsProjectPath(rawDestination, browseEnvironmentPlatform)) { |
| 2615 | toastManager.add( | 2640 | toastManager.add( |
| ... | @@ -2948,9 +2973,21 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2948,9 +2973,21 @@ function OpenCommandPaletteDialog(props: { |
| 2948 | getCommandPaletteInputPlaceholder(paletteMode)); | 2973 | getCommandPaletteInputPlaceholder(paletteMode)); |
| 2949 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; | 2974 | const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; |
| 2950 | const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; | 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 | const canSubmitBrowsePath = | 2986 | const canSubmitBrowsePath = |
| 2952 | isBrowsing && | 2987 | isBrowsing && |
| 2953 | !relativePathNeedsActiveProject && | 2988 | !relativePathNeedsActiveProject && |
| 2989 | canCreateProject && | ||
| 2990 | (!isCloneDestinationStep || canCloneProject) && | ||
| 2954 | canCreateProjectInEnvironment(browseEnvironment?.connection.phase); | 2991 | canCreateProjectInEnvironment(browseEnvironment?.connection.phase); |
| 2955 | const willCreateProjectPath = | 2992 | const willCreateProjectPath = |
| 2956 | canSubmitBrowsePath && | 2993 | canSubmitBrowsePath && |
| ... | @@ -2960,7 +2997,6 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2960,7 +2997,6 @@ function OpenCommandPaletteDialog(props: { |
| 2960 | (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); | 2997 | (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); |
| 2961 | const useMetaForMod = isMacPlatform(navigator.platform); | 2998 | const useMetaForMod = isMacPlatform(navigator.platform); |
| 2962 | const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl"; | 2999 | const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl"; |
| 2963 | const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; | ||
| 2964 | const submitActionLabel = isCloneDestinationStep | 3000 | const submitActionLabel = isCloneDestinationStep |
| 2965 | ? willCreateProjectPath | 3001 | ? willCreateProjectPath |
| 2966 | ? "Create & Clone" | 3002 | ? "Create & Clone" |
| ... | @@ -3327,9 +3363,10 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3327,9 +3363,10 @@ function OpenCommandPaletteDialog(props: { |
| 3327 | className="absolute inset-e-2.5 top-1/2 -translate-y-1/2" | 3363 | className="absolute inset-e-2.5 top-1/2 -translate-y-1/2" |
| 3328 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} | 3364 | aria-label={`${submitActionLabel} (${addShortcutLabel})`} |
| 3329 | disabled={ | 3365 | disabled={ |
| 3366 | !canCreateProject || | ||
| 3330 | !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || | 3367 | !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || |
| 3331 | relativePathNeedsActiveProject || | 3368 | relativePathNeedsActiveProject || |
| 3332 | (isCloneDestinationStep && isRemoteProjectPending) | 3369 | (isCloneDestinationStep && (!canCloneProject || isRemoteProjectPending)) |
| 3333 | } | 3370 | } |
| 3334 | onMouseDown={(event) => { | 3371 | onMouseDown={(event) => { |
| 3335 | event.preventDefault(); | 3372 | event.preventDefault(); |
| ... | @@ -3355,7 +3392,11 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -3355,7 +3392,11 @@ function OpenCommandPaletteDialog(props: { |
| 3355 | </KbdGroup> | 3392 | </KbdGroup> |
| 3356 | </TooltipTrigger> | 3393 | </TooltipTrigger> |
| 3357 | <TooltipPopup side="top"> | 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 | </TooltipPopup> | 3400 | </TooltipPopup> |
| 3360 | </Tooltip> | 3401 | </Tooltip> |
| 3361 | ) : null; | 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,6 +1,10 @@ |
| 1 | import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; | 1 | import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 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 | import { | 8 | import { |
| 5 | isAtomCommandInterrupted, | 9 | isAtomCommandInterrupted, |
| 6 | squashAtomCommandFailure, | 10 | squashAtomCommandFailure, |
| ... | @@ -108,7 +112,8 @@ import { | ... | @@ -108,7 +112,8 @@ import { |
| 108 | useVcsInitAction, | 112 | useVcsInitAction, |
| 109 | useVcsPullAction, | 113 | useVcsPullAction, |
| 110 | } from "~/lib/sourceControlActions"; | 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 | import { useEnvironmentQuery } from "~/state/query"; | 117 | import { useEnvironmentQuery } from "~/state/query"; |
| 113 | import { serverEnvironment } from "~/state/server"; | 118 | import { serverEnvironment } from "~/state/server"; |
| 114 | import { sourceControlEnvironment } from "~/state/sourceControl"; | 119 | import { sourceControlEnvironment } from "~/state/sourceControl"; |
| ... | @@ -630,14 +635,19 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { | ... | @@ -630,14 +635,19 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { |
| 630 | ] as const; | 635 | ] as const; |
| 631 | 636 | ||
| 632 | const canSubmitPublishRepository = useMemo(() => { | 637 | const canSubmitPublishRepository = useMemo(() => { |
| 633 | if (!selectedPublishProviderReadiness.ready) return false; | 638 | if (!publishRepositoryAction.isAllowed || !selectedPublishProviderReadiness.ready) return false; |
| 634 | if (publishRepositoryAction.isPending) return false; | 639 | if (publishRepositoryAction.isPending) return false; |
| 635 | const repositoryParts = publishRepository.trim().split("/"); | 640 | const repositoryParts = publishRepository.trim().split("/"); |
| 636 | const owner = repositoryParts[0]?.trim() ?? ""; | 641 | const owner = repositoryParts[0]?.trim() ?? ""; |
| 637 | const rest = repositoryParts.slice(1); | 642 | const rest = repositoryParts.slice(1); |
| 638 | const name = rest.join("/").trim(); | 643 | const name = rest.join("/").trim(); |
| 639 | return owner.length > 0 && name.length > 0; | 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 | const submitPublishRepository = useCallback(() => { | 652 | const submitPublishRepository = useCallback(() => { |
| 643 | if (!canSubmitPublishRepository) { | 653 | if (!canSubmitPublishRepository) { |
| ... | @@ -1070,6 +1080,11 @@ export default function GitActionsControl({ | ... | @@ -1070,6 +1080,11 @@ export default function GitActionsControl({ |
| 1070 | ); | 1080 | ); |
| 1071 | const activeEnvironmentId = activeThreadRef?.environmentId ?? null; | 1081 | const activeEnvironmentId = activeThreadRef?.environmentId ?? null; |
| 1072 | const successScopeKey = `${activeEnvironmentId ?? ""}\u0000${gitCwd ?? ""}`; | 1082 | const successScopeKey = `${activeEnvironmentId ?? ""}\u0000${gitCwd ?? ""}`; |
| 1083 | const canWriteSourceControl = useEnvironmentScope( | ||
| 1084 | activeEnvironmentId, | ||
| 1085 | AuthSourceControlWriteScope, | ||
| 1086 | ); | ||
| 1087 | const canOperateThread = useEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope); | ||
| 1073 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId)); | 1088 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId)); |
| 1074 | const openInPreferredEditor = useOpenInPreferredEditor( | 1089 | const openInPreferredEditor = useOpenInPreferredEditor( |
| 1075 | activeEnvironmentId, | 1090 | activeEnvironmentId, |
| ... | @@ -1079,7 +1094,7 @@ export default function GitActionsControl({ | ... | @@ -1079,7 +1094,7 @@ export default function GitActionsControl({ |
| 1079 | () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), | 1094 | () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), |
| 1080 | [activeThreadRef], | 1095 | [activeThreadRef], |
| 1081 | ); | 1096 | ); |
| 1082 | const activeServerThread = useThreadShell(activeThreadRef); | 1097 | const activeServerThreadShell = useThreadShell(activeThreadRef); |
| 1083 | const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); | 1098 | const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); |
| 1084 | const activeDraftThread = useComposerDraftStore((store) => | 1099 | const activeDraftThread = useComposerDraftStore((store) => |
| 1085 | draftId | 1100 | draftId |
| ... | @@ -1088,6 +1103,13 @@ export default function GitActionsControl({ | ... | @@ -1088,6 +1103,13 @@ export default function GitActionsControl({ |
| 1088 | ? store.getDraftThreadByRef(activeThreadRef) | 1103 | ? store.getDraftThreadByRef(activeThreadRef) |
| 1089 | : null, | 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 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 1113 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 1092 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); | 1114 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); |
| 1093 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); | 1115 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| ... | @@ -1120,7 +1142,10 @@ export default function GitActionsControl({ | ... | @@ -1120,7 +1142,10 @@ export default function GitActionsControl({ |
| 1120 | } | 1142 | } |
| 1121 | 1143 | ||
| 1122 | if (activeServerThread) { | 1144 | if (activeServerThread) { |
| 1123 | if (activeServerThread.branch === branch) { | 1145 | if ( |
| 1146 | !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) || | ||
| 1147 | activeServerThread.branch === branch | ||
| 1148 | ) { | ||
| 1124 | return; | 1149 | return; |
| 1125 | } | 1150 | } |
| 1126 | 1151 | ||
| ... | @@ -1248,9 +1273,11 @@ export default function GitActionsControl({ | ... | @@ -1248,9 +1273,11 @@ export default function GitActionsControl({ |
| 1248 | resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), | 1273 | resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), |
| 1249 | [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], | 1274 | [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], |
| 1250 | ); | 1275 | ); |
| 1251 | const quickActionDisabledReason = quickAction.disabled | 1276 | const quickActionDisabledReason = !canWriteSourceControl |
| 1252 | ? (quickAction.hint ?? "This action is currently unavailable.") | 1277 | ? "This connection cannot change source control." |
| 1253 | : null; | 1278 | : quickAction.disabled |
| 1279 | ? (quickAction.hint ?? "This action is currently unavailable.") | ||
| 1280 | : null; | ||
| 1254 | const gitActionProgress = resolveGitActionProgressPresentation(vcsActionState); | 1281 | const gitActionProgress = resolveGitActionProgressPresentation(vcsActionState); |
| 1255 | const pendingDefaultBranchActionCopy = pendingDefaultBranchAction | 1282 | const pendingDefaultBranchActionCopy = pendingDefaultBranchAction |
| 1256 | ? resolveDefaultBranchActionDialogCopy({ | 1283 | ? resolveDefaultBranchActionDialogCopy({ |
| ... | @@ -1304,6 +1331,14 @@ export default function GitActionsControl({ | ... | @@ -1304,6 +1331,14 @@ export default function GitActionsControl({ |
| 1304 | featureBranch = false, | 1331 | featureBranch = false, |
| 1305 | filePaths, | 1332 | filePaths, |
| 1306 | }: RunGitActionWithToastInput) => { | 1333 | }: RunGitActionWithToastInput) => { |
| 1334 | if ( | ||
| 1335 | activeEnvironmentId === null || | ||
| 1336 | !readEnvironmentScope(activeEnvironmentId, AuthSourceControlWriteScope) || | ||
| 1337 | (featureBranch && | ||
| 1338 | !isLocalDraftThread && | ||
| 1339 | !readEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope)) | ||
| 1340 | ) | ||
| 1341 | return; | ||
| 1307 | const actionStatus = statusOverride ?? gitStatusForActions; | 1342 | const actionStatus = statusOverride ?? gitStatusForActions; |
| 1308 | const actionBranch = actionStatus?.refName ?? null; | 1343 | const actionBranch = actionStatus?.refName ?? null; |
| 1309 | const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; | 1344 | const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; |
| ... | @@ -1459,7 +1494,7 @@ export default function GitActionsControl({ | ... | @@ -1459,7 +1494,7 @@ export default function GitActionsControl({ |
| 1459 | }; | 1494 | }; |
| 1460 | 1495 | ||
| 1461 | const checkoutFeatureBranchAndContinuePendingAction = () => { | 1496 | const checkoutFeatureBranchAndContinuePendingAction = () => { |
| 1462 | if (!pendingDefaultBranchAction) return; | 1497 | if (!canChangeThreadBranch || !pendingDefaultBranchAction) return; |
| 1463 | const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; | 1498 | const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; |
| 1464 | setPendingDefaultBranchAction(null); | 1499 | setPendingDefaultBranchAction(null); |
| 1465 | void runGitActionWithToast({ | 1500 | void runGitActionWithToast({ |
| ... | @@ -1473,7 +1508,7 @@ export default function GitActionsControl({ | ... | @@ -1473,7 +1508,7 @@ export default function GitActionsControl({ |
| 1473 | }; | 1508 | }; |
| 1474 | 1509 | ||
| 1475 | const runDialogActionOnNewBranch = () => { | 1510 | const runDialogActionOnNewBranch = () => { |
| 1476 | if (!isCommitDialogOpen) return; | 1511 | if (!canChangeThreadBranch || !isCommitDialogOpen) return; |
| 1477 | const commitMessage = dialogCommitMessage.trim(); | 1512 | const commitMessage = dialogCommitMessage.trim(); |
| 1478 | 1513 | ||
| 1479 | setIsCommitDialogOpen(false); | 1514 | setIsCommitDialogOpen(false); |
| ... | @@ -1616,7 +1651,8 @@ export default function GitActionsControl({ | ... | @@ -1616,7 +1651,8 @@ export default function GitActionsControl({ |
| 1616 | [gitCwd, openInPreferredEditor, threadToastData], | 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 | const initializeGit = () => { | 1657 | const initializeGit = () => { |
| 1622 | void (async () => { | 1658 | void (async () => { |
| ... | @@ -1683,7 +1719,7 @@ export default function GitActionsControl({ | ... | @@ -1683,7 +1719,7 @@ export default function GitActionsControl({ |
| 1683 | <MenuItem | 1719 | <MenuItem |
| 1684 | density={presentation === "menu" ? "touch" : "default"} | 1720 | density={presentation === "menu" ? "touch" : "default"} |
| 1685 | key={`${item.id}-${item.label}`} | 1721 | key={`${item.id}-${item.label}`} |
| 1686 | disabled={item.disabled} | 1722 | disabled={!canWriteSourceControl || item.disabled} |
| 1687 | onClick={() => { | 1723 | onClick={() => { |
| 1688 | openDialogForMenuItem(item); | 1724 | openDialogForMenuItem(item); |
| 1689 | }} | 1725 | }} |
| ... | @@ -1777,7 +1813,7 @@ export default function GitActionsControl({ | ... | @@ -1777,7 +1813,7 @@ export default function GitActionsControl({ |
| 1777 | variant={isPanel ? "ghost" : "outline"} | 1813 | variant={isPanel ? "ghost" : "outline"} |
| 1778 | part="row" | 1814 | part="row" |
| 1779 | panel={isPanel} | 1815 | panel={isPanel} |
| 1780 | disabled={initAction.isPending} | 1816 | disabled={!canWriteSourceControl || initAction.isPending} |
| 1781 | onClick={initializeGit} | 1817 | onClick={initializeGit} |
| 1782 | > | 1818 | > |
| 1783 | <GitBranchPlusIcon className="size-3.5" aria-hidden /> | 1819 | <GitBranchPlusIcon className="size-3.5" aria-hidden /> |
| ... | @@ -1855,7 +1891,7 @@ export default function GitActionsControl({ | ... | @@ -1855,7 +1891,7 @@ export default function GitActionsControl({ |
| 1855 | size="xs" | 1891 | size="xs" |
| 1856 | part="primary" | 1892 | part="primary" |
| 1857 | panel={isPanel} | 1893 | panel={isPanel} |
| 1858 | disabled={isGitActionRunning || quickAction.disabled} | 1894 | disabled={!canWriteSourceControl || isGitActionRunning || quickAction.disabled} |
| 1859 | onClick={runQuickAction} | 1895 | onClick={runQuickAction} |
| 1860 | > | 1896 | > |
| 1861 | <GitQuickActionIcon | 1897 | <GitQuickActionIcon |
| ... | @@ -2035,6 +2071,8 @@ export default function GitActionsControl({ | ... | @@ -2035,6 +2071,8 @@ export default function GitActionsControl({ |
| 2035 | <button | 2071 | <button |
| 2036 | type="button" | 2072 | type="button" |
| 2037 | className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left" | 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 | onClick={() => openChangedFileInEditor(file.path)} | 2076 | onClick={() => openChangedFileInEditor(file.path)} |
| 2039 | > | 2077 | > |
| 2040 | <StartTruncatedPath | 2078 | <StartTruncatedPath |
| ... | @@ -2102,12 +2140,16 @@ export default function GitActionsControl({ | ... | @@ -2102,12 +2140,16 @@ export default function GitActionsControl({ |
| 2102 | <Button | 2140 | <Button |
| 2103 | variant="outline" | 2141 | variant="outline" |
| 2104 | size="sm" | 2142 | size="sm" |
| 2105 | disabled={noneSelected} | 2143 | disabled={!canChangeThreadBranch || noneSelected} |
| 2106 | onClick={runDialogActionOnNewBranch} | 2144 | onClick={runDialogActionOnNewBranch} |
| 2107 | > | 2145 | > |
| 2108 | Commit on new branch | 2146 | Commit on new branch |
| 2109 | </Button> | 2147 | </Button> |
| 2110 | <Button size="sm" disabled={noneSelected} onClick={runDialogAction}> | 2148 | <Button |
| 2149 | size="sm" | ||
| 2150 | disabled={!canWriteSourceControl || noneSelected} | ||
| 2151 | onClick={runDialogAction} | ||
| 2152 | > | ||
| 2111 | Commit | 2153 | Commit |
| 2112 | </Button> | 2154 | </Button> |
| 2113 | </DialogFooter> | 2155 | </DialogFooter> |
| ... | @@ -2151,6 +2193,7 @@ export default function GitActionsControl({ | ... | @@ -2151,6 +2193,7 @@ export default function GitActionsControl({ |
| 2151 | variant="outline" | 2193 | variant="outline" |
| 2152 | size="sm-multiline" | 2194 | size="sm-multiline" |
| 2153 | onClick={continuePendingDefaultBranchAction} | 2195 | onClick={continuePendingDefaultBranchAction} |
| 2196 | disabled={!canWriteSourceControl} | ||
| 2154 | > | 2197 | > |
| 2155 | {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"} | 2198 | {pendingDefaultBranchActionCopy?.continueLabel ?? "Continue"} |
| 2156 | </Button> | 2199 | </Button> |
| ... | @@ -2158,6 +2201,7 @@ export default function GitActionsControl({ | ... | @@ -2158,6 +2201,7 @@ export default function GitActionsControl({ |
| 2158 | className="w-full max-w-full sm:w-auto" | 2201 | className="w-full max-w-full sm:w-auto" |
| 2159 | size="sm-multiline" | 2202 | size="sm-multiline" |
| 2160 | onClick={checkoutFeatureBranchAndContinuePendingAction} | 2203 | onClick={checkoutFeatureBranchAndContinuePendingAction} |
| 2204 | disabled={!canChangeThreadBranch} | ||
| 2161 | > | 2205 | > |
| 2162 | Check out feature branch & continue | 2206 | Check out feature branch & continue |
| 2163 | </Button> | 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,5 +1,12 @@ |
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { |
| 2 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; | 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 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 10 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 11 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | 12 | ||
| ... | @@ -12,6 +19,7 @@ import { cn } from "~/lib/utils"; | ... | @@ -12,6 +19,7 @@ import { cn } from "~/lib/utils"; |
| 12 | import { parsePullRequestReference } from "~/pullRequestReference"; | 19 | import { parsePullRequestReference } from "~/pullRequestReference"; |
| 13 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; | 20 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; |
| 14 | import { useEnvironmentQuery } from "~/state/query"; | 21 | import { useEnvironmentQuery } from "~/state/query"; |
| 22 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | ||
| 15 | import { vcsEnvironment } from "~/state/vcs"; | 23 | import { vcsEnvironment } from "~/state/vcs"; |
| 16 | import { Button } from "./ui/button"; | 24 | import { Button } from "./ui/button"; |
| 17 | import { | 25 | import { |
| ... | @@ -49,6 +57,7 @@ export function PullRequestThreadDialog({ | ... | @@ -49,6 +57,7 @@ export function PullRequestThreadDialog({ |
| 49 | const [reference, setReference] = useState(initialReference ?? ""); | 57 | const [reference, setReference] = useState(initialReference ?? ""); |
| 50 | const [referenceDirty, setReferenceDirty] = useState(false); | 58 | const [referenceDirty, setReferenceDirty] = useState(false); |
| 51 | const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); | 59 | const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); |
| 60 | const [prepareErrorMessage, setPrepareErrorMessage] = useState<string | null>(null); | ||
| 52 | const [debouncedReference, referenceDebouncer] = useDebouncedValue( | 61 | const [debouncedReference, referenceDebouncer] = useDebouncedValue( |
| 53 | reference, | 62 | reference, |
| 54 | { wait: 450 }, | 63 | { wait: 450 }, |
| ... | @@ -102,6 +111,7 @@ export function PullRequestThreadDialog({ | ... | @@ -102,6 +111,7 @@ export function PullRequestThreadDialog({ |
| 102 | ); | 111 | ); |
| 103 | }, [parsedReference, sourceControlScope]); | 112 | }, [parsedReference, sourceControlScope]); |
| 104 | const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope); | 113 | const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope); |
| 114 | const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); | ||
| 105 | 115 | ||
| 106 | const liveResolvedPullRequest = | 116 | const liveResolvedPullRequest = |
| 107 | parsedReference !== null && parsedReference === parsedDebouncedReference | 117 | parsedReference !== null && parsedReference === parsedDebouncedReference |
| ... | @@ -131,6 +141,13 @@ export function PullRequestThreadDialog({ | ... | @@ -131,6 +141,13 @@ export function PullRequestThreadDialog({ |
| 131 | 141 | ||
| 132 | const handleConfirm = useCallback( | 142 | const handleConfirm = useCallback( |
| 133 | async (mode: "local" | "worktree") => { | 143 | async (mode: "local" | "worktree") => { |
| 144 | if (!preparePullRequestThreadAction.isAllowed) return; | ||
| 145 | if ( | ||
| 146 | mode === "worktree" && | ||
| 147 | !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) | ||
| 148 | ) { | ||
| 149 | return; | ||
| 150 | } | ||
| 134 | if (!parsedReference) { | 151 | if (!parsedReference) { |
| 135 | setReferenceDirty(true); | 152 | setReferenceDirty(true); |
| 136 | return; | 153 | return; |
| ... | @@ -138,6 +155,7 @@ export function PullRequestThreadDialog({ | ... | @@ -138,6 +155,7 @@ export function PullRequestThreadDialog({ |
| 138 | if (!parsedReference || !resolvedPullRequest || !cwd) { | 155 | if (!parsedReference || !resolvedPullRequest || !cwd) { |
| 139 | return; | 156 | return; |
| 140 | } | 157 | } |
| 158 | setPrepareErrorMessage(null); | ||
| 141 | setPreparingMode(mode); | 159 | setPreparingMode(mode); |
| 142 | const result = await preparePullRequestThreadAction.run({ | 160 | const result = await preparePullRequestThreadAction.run({ |
| 143 | reference: parsedReference, | 161 | reference: parsedReference, |
| ... | @@ -148,6 +166,13 @@ export function PullRequestThreadDialog({ | ... | @@ -148,6 +166,13 @@ export function PullRequestThreadDialog({ |
| 148 | if (result._tag === "Failure") { | 166 | if (result._tag === "Failure") { |
| 149 | if (isAtomCommandInterrupted(result)) { | 167 | if (isAtomCommandInterrupted(result)) { |
| 150 | preparePullRequestThreadAction.resetError(); | 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 | return; | 177 | return; |
| 153 | } | 178 | } |
| ... | @@ -159,11 +184,13 @@ export function PullRequestThreadDialog({ | ... | @@ -159,11 +184,13 @@ export function PullRequestThreadDialog({ |
| 159 | }, | 184 | }, |
| 160 | [ | 185 | [ |
| 161 | cwd, | 186 | cwd, |
| 187 | environmentId, | ||
| 162 | onOpenChange, | 188 | onOpenChange, |
| 163 | onPrepared, | 189 | onPrepared, |
| 164 | parsedReference, | 190 | parsedReference, |
| 165 | preparePullRequestThreadAction, | 191 | preparePullRequestThreadAction, |
| 166 | resolvedPullRequest, | 192 | resolvedPullRequest, |
| 193 | terminology.singular, | ||
| 167 | threadId, | 194 | threadId, |
| 168 | ], | 195 | ], |
| 169 | ); | 196 | ); |
| ... | @@ -177,6 +204,7 @@ export function PullRequestThreadDialog({ | ... | @@ -177,6 +204,7 @@ export function PullRequestThreadDialog({ |
| 177 | : null; | 204 | : null; |
| 178 | const errorMessage = | 205 | const errorMessage = |
| 179 | validationMessage ?? | 206 | validationMessage ?? |
| 207 | prepareErrorMessage ?? | ||
| 180 | (resolvedPullRequest === null && pullRequestResolution.error | 208 | (resolvedPullRequest === null && pullRequestResolution.error |
| 181 | ? pullRequestResolution.error | 209 | ? pullRequestResolution.error |
| 182 | : preparePullRequestThreadAction.error instanceof Error | 210 | : preparePullRequestThreadAction.error instanceof Error |
| ... | @@ -273,10 +301,9 @@ export function PullRequestThreadDialog({ | ... | @@ -273,10 +301,9 @@ export function PullRequestThreadDialog({ |
| 273 | type="button" | 301 | type="button" |
| 274 | size="sm" | 302 | size="sm" |
| 275 | variant="outline" | 303 | variant="outline" |
| 276 | onClick={() => { | 304 | onClick={() => handleConfirm("local")} |
| 277 | void handleConfirm("local"); | ||
| 278 | }} | ||
| 279 | disabled={ | 305 | disabled={ |
| 306 | !preparePullRequestThreadAction.isAllowed || | ||
| 280 | !cwd || | 307 | !cwd || |
| 281 | !resolvedPullRequest || | 308 | !resolvedPullRequest || |
| 282 | isResolving || | 309 | isResolving || |
| ... | @@ -288,10 +315,10 @@ export function PullRequestThreadDialog({ | ... | @@ -288,10 +315,10 @@ export function PullRequestThreadDialog({ |
| 288 | <Button | 315 | <Button |
| 289 | type="button" | 316 | type="button" |
| 290 | size="sm" | 317 | size="sm" |
| 291 | onClick={() => { | 318 | onClick={() => handleConfirm("worktree")} |
| 292 | void handleConfirm("worktree"); | ||
| 293 | }} | ||
| 294 | disabled={ | 319 | disabled={ |
| 320 | !preparePullRequestThreadAction.isAllowed || | ||
| 321 | !canOperateThread || | ||
| 295 | !cwd || | 322 | !cwd || |
| 296 | !resolvedPullRequest || | 323 | !resolvedPullRequest || |
| 297 | isResolving || | 324 | isResolving || |
apps/web/src/components/Sidebar.tsx+183-61| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { type EnvironmentId } from "@t3tools/contracts"; | ||
| 1 | import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; | 2 | import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; |
| 2 | import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; | 3 | import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; |
| 3 | import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators"; | 4 | import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators"; |
| ... | @@ -52,6 +53,7 @@ import { | ... | @@ -52,6 +53,7 @@ import { |
| 52 | scopedThreadKey, | 53 | scopedThreadKey, |
| 53 | } from "@t3tools/client-runtime/environment"; | 54 | } from "@t3tools/client-runtime/environment"; |
| 54 | import { | 55 | import { |
| 56 | AuthOrchestrationOperateScope, | ||
| 55 | type EnvironmentMachineKind, | 57 | type EnvironmentMachineKind, |
| 56 | type ScopedThreadRef, | 58 | type ScopedThreadRef, |
| 57 | type ThreadId, | 59 | type ThreadId, |
| ... | @@ -158,7 +160,8 @@ import { vcsEnvironment } from "../state/vcs"; | ... | @@ -158,7 +160,8 @@ import { vcsEnvironment } from "../state/vcs"; |
| 158 | import { threadEnvironment } from "../state/threads"; | 160 | import { threadEnvironment } from "../state/threads"; |
| 159 | import { useEnvironmentQuery } from "../state/query"; | 161 | import { useEnvironmentQuery } from "../state/query"; |
| 160 | import { useThreadSearch } from "../state/queries"; | 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 | import { | 165 | import { |
| 163 | buildThreadRouteParams, | 166 | buildThreadRouteParams, |
| 164 | resolveActiveThreadRouteRef, | 167 | resolveActiveThreadRouteRef, |
| ... | @@ -170,7 +173,11 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | ... | @@ -170,7 +173,11 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 170 | import { cn } from "~/lib/utils"; | 173 | import { cn } from "~/lib/utils"; |
| 171 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 174 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 172 | import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; | 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 | import { | 181 | import { |
| 175 | animateSidebarLayoutChanges, | 182 | animateSidebarLayoutChanges, |
| 176 | applySidebarThreadDrop, | 183 | applySidebarThreadDrop, |
| ... | @@ -296,6 +303,26 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; | ... | @@ -296,6 +303,26 @@ const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; |
| 296 | // Module scope keeps the inbox order across routes that unmount the sidebar. | 303 | // Module scope keeps the inbox order across routes that unmount the sidebar. |
| 297 | const inboxReturns = createInboxReturnTracker(); | 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 | function compactSidebarTimeLabel(label: string): string { | 326 | function compactSidebarTimeLabel(label: string): string { |
| 300 | if (label === "just now") return "now"; | 327 | if (label === "just now") return "now"; |
| 301 | return label.endsWith(" ago") ? label.slice(0, -4) : label; | 328 | return label.endsWith(" ago") ? label.slice(0, -4) : label; |
| ... | @@ -619,11 +646,13 @@ function SortableThreadRow(props: { | ... | @@ -619,11 +646,13 @@ function SortableThreadRow(props: { |
| 619 | id: string; | 646 | id: string; |
| 620 | disabled: boolean; | 647 | disabled: boolean; |
| 621 | contextDrag: boolean; | 648 | contextDrag: boolean; |
| 649 | environmentId: EnvironmentId; | ||
| 622 | children: (bag: SortableThreadRowBag) => ReactNode; | 650 | children: (bag: SortableThreadRowBag) => ReactNode; |
| 623 | }) { | 651 | }) { |
| 652 | const canOperateThread = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope); | ||
| 624 | const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ | 653 | const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ |
| 625 | id: props.id, | 654 | id: props.id, |
| 626 | disabled: { draggable: props.disabled }, | 655 | disabled: { draggable: props.disabled || !canOperateThread }, |
| 627 | animateLayoutChanges: animateSidebarLayoutChanges, | 656 | animateLayoutChanges: animateSidebarLayoutChanges, |
| 628 | }); | 657 | }); |
| 629 | // dnd-kit memoizes each field but not the bag, so the memoized row would | 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,6 +1202,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1173 | [thread.environmentId, thread.id], | 1202 | [thread.environmentId, thread.id], |
| 1174 | ); | 1203 | ); |
| 1175 | const threadKey = scopedThreadKey(threadRef); | 1204 | const threadKey = scopedThreadKey(threadRef); |
| 1205 | const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); | ||
| 1206 | useEffect(() => { | ||
| 1207 | if (!canOperateThread && isRenaming) onCancelRename(); | ||
| 1208 | }, [canOperateThread, isRenaming, onCancelRename]); | ||
| 1176 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); | 1209 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); |
| 1177 | const isRegeneratingTitle = thread.titleRegeneration != null; | 1210 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 1178 | const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); | 1211 | const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); |
| ... | @@ -1389,7 +1422,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1389,7 +1422,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1389 | ); | 1422 | ); |
| 1390 | const handleDoubleClick = useCallback( | 1423 | const handleDoubleClick = useCallback( |
| 1391 | (event: ReactMouseEvent) => { | 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 | return; | 1433 | return; |
| 1394 | } | 1434 | } |
| 1395 | if ((event.target as HTMLElement).closest("button, a, input")) return; | 1435 | if ((event.target as HTMLElement).closest("button, a, input")) return; |
| ... | @@ -1497,7 +1537,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1497,7 +1537,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1497 | // Snooze is offered only where it can succeed: capability-gated and never | 1537 | // Snooze is offered only where it can succeed: capability-gated and never |
| 1498 | // on blocked-on-you work or queued turns (the server rejects both). | 1538 | // on blocked-on-you work or queued turns (the server rejects both). |
| 1499 | const showSnoozeButton = | 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 | // If the thread becomes blocked while the popover is open, the button | 1545 | // If the thread becomes blocked while the popover is open, the button |
| 1502 | // unmounts without firing onOpenChange(false). Deriving the flag keeps a | 1546 | // unmounts without firing onOpenChange(false). Deriving the flag keeps a |
| 1503 | // stale true from permanently hiding the status label / pinning the | 1547 | // stale true from permanently hiding the status label / pinning the |
| ... | @@ -1600,52 +1644,53 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1600,52 +1644,53 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1600 | isActive: props.isActive, | 1644 | isActive: props.isActive, |
| 1601 | }); | 1645 | }); |
| 1602 | 1646 | ||
| 1603 | const title = isRenaming ? ( | 1647 | const title = |
| 1604 | <input | 1648 | isRenaming && canOperateThread ? ( |
| 1605 | autoFocus | 1649 | <input |
| 1606 | value={renamingTitle} | 1650 | autoFocus |
| 1607 | aria-label="Thread title" | 1651 | value={renamingTitle} |
| 1608 | onChange={(event) => onRenameTitleChange(event.target.value)} | 1652 | aria-label="Thread title" |
| 1609 | onFocus={(event) => event.currentTarget.select()} | 1653 | onChange={(event) => onRenameTitleChange(event.target.value)} |
| 1610 | onKeyDown={handleRenameKeyDown} | 1654 | onFocus={(event) => event.currentTarget.select()} |
| 1611 | onBlur={handleRenameBlur} | 1655 | onKeyDown={handleRenameKeyDown} |
| 1612 | onClick={(event) => event.stopPropagation()} | 1656 | onBlur={handleRenameBlur} |
| 1613 | onDoubleClick={(event) => event.stopPropagation()} | 1657 | onClick={(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" | 1658 | onDoubleClick={(event) => event.stopPropagation()} |
| 1615 | /> | 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" |
| 1616 | ) : ( | 1660 | /> |
| 1617 | <span | 1661 | ) : ( |
| 1618 | aria-hidden | 1662 | <span |
| 1619 | className={cn( | 1663 | aria-hidden |
| 1620 | "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none", | 1664 | className={cn( |
| 1621 | shouldRecede ? "font-normal" : "font-medium", | 1665 | "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none", |
| 1622 | variant === "card" | 1666 | shouldRecede ? "font-normal" : "font-medium", |
| 1623 | ? cn( | 1667 | variant === "card" |
| 1624 | "truncate", | 1668 | ? cn( |
| 1625 | shouldRecede | 1669 | "truncate", |
| 1626 | ? "text-secondary-label" | 1670 | shouldRecede |
| 1627 | : isUnread || isWoke || status === "input" | 1671 | ? "text-secondary-label" |
| 1628 | ? "text-foreground" | 1672 | : isUnread || isWoke || status === "input" |
| 1629 | : status === "failed" | 1673 | ? "text-foreground" |
| 1630 | ? "text-foreground/95" | 1674 | : status === "failed" |
| 1631 | : "text-foreground/90", | 1675 | ? "text-foreground/95" |
| 1632 | ) | 1676 | : "text-foreground/90", |
| 1633 | : cn( | 1677 | ) |
| 1634 | "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground", | 1678 | : cn( |
| 1635 | shouldRecede | 1679 | "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground", |
| 1636 | ? "text-secondary-label/70" | 1680 | shouldRecede |
| 1637 | : props.isActive || isWoke || status === "input" | 1681 | ? "text-secondary-label/70" |
| 1638 | ? "text-foreground" | 1682 | : props.isActive || isWoke || status === "input" |
| 1639 | : isUnread | 1683 | ? "text-foreground" |
| 1640 | ? "text-muted-foreground" | 1684 | : isUnread |
| 1641 | : "text-secondary-label/70", | 1685 | ? "text-muted-foreground" |
| 1642 | ), | 1686 | : "text-secondary-label/70", |
| 1643 | isRegeneratingTitle && "opacity-55", | 1687 | ), |
| 1644 | )} | 1688 | isRegeneratingTitle && "opacity-55", |
| 1645 | > | 1689 | )} |
| 1646 | {thread.title} | 1690 | > |
| 1647 | </span> | 1691 | {thread.title} |
| 1648 | ); | 1692 | </span> |
| 1693 | ); | ||
| 1649 | const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>; | 1694 | const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>; |
| 1650 | 1695 | ||
| 1651 | // Stacks show their layer count; multiple unrelated links show their total count. | 1696 | // Stacks show their layer count; multiple unrelated links show their total count. |
| ... | @@ -1706,7 +1751,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1706,7 +1751,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1706 | const showPin = | 1751 | const showPin = |
| 1707 | props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); | 1752 | props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); |
| 1708 | const pinIndicator = showPin ? ( | 1753 | const pinIndicator = showPin ? ( |
| 1709 | props.pinningSupported && !sortable?.isDragging ? ( | 1754 | props.pinningSupported && canOperateThread && !sortable?.isDragging ? ( |
| 1710 | <Tooltip> | 1755 | <Tooltip> |
| 1711 | <TooltipTrigger | 1756 | <TooltipTrigger |
| 1712 | render={ | 1757 | render={ |
| ... | @@ -2350,7 +2395,7 @@ export default function Sidebar() { | ... | @@ -2350,7 +2395,7 @@ export default function Sidebar() { |
| 2350 | archiveThread, | 2395 | archiveThread, |
| 2351 | deleteThread, | 2396 | deleteThread, |
| 2352 | } = useThreadActions(); | 2397 | } = useThreadActions(); |
| 2353 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | 2398 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { |
| 2354 | reportFailure: false, | 2399 | reportFailure: false, |
| 2355 | }); | 2400 | }); |
| 2356 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ | 2401 | const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ |
| ... | @@ -3180,6 +3225,7 @@ export default function Sidebar() { | ... | @@ -3180,6 +3225,7 @@ export default function Sidebar() { |
| 3180 | const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null); | 3225 | const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null); |
| 3181 | const [renamingTitle, setRenamingTitle] = useState(""); | 3226 | const [renamingTitle, setRenamingTitle] = useState(""); |
| 3182 | const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { | 3227 | const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { |
| 3228 | if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; | ||
| 3183 | setRenamingThreadKey(scopedThreadKey(threadRef)); | 3229 | setRenamingThreadKey(scopedThreadKey(threadRef)); |
| 3184 | setRenamingTitle(title); | 3230 | setRenamingTitle(title); |
| 3185 | }, []); | 3231 | }, []); |
| ... | @@ -3187,6 +3233,7 @@ export default function Sidebar() { | ... | @@ -3187,6 +3233,7 @@ export default function Sidebar() { |
| 3187 | const commitThreadRename = useCallback( | 3233 | const commitThreadRename = useCallback( |
| 3188 | (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { | 3234 | (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { |
| 3189 | void (async () => { | 3235 | void (async () => { |
| 3236 | if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; | ||
| 3190 | const trimmed = title.trim(); | 3237 | const trimmed = title.trim(); |
| 3191 | setRenamingThreadKey(null); | 3238 | setRenamingThreadKey(null); |
| 3192 | if (trimmed.length === 0) { | 3239 | if (trimmed.length === 0) { |
| ... | @@ -3960,6 +4007,17 @@ export default function Sidebar() { | ... | @@ -3960,6 +4007,17 @@ export default function Sidebar() { |
| 3960 | : plan.kind === "reorder-pinned" || plan.kind === "move-active" | 4007 | : plan.kind === "reorder-pinned" || plan.kind === "move-active" |
| 3961 | ? plan.assignments | 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 | const drop = { | 4021 | const drop = { |
| 3964 | key: activeKey, | 4022 | key: activeKey, |
| 3965 | sourceSection: activeSection, | 4023 | sourceSection: activeSection, |
| ... | @@ -4174,6 +4232,9 @@ export default function Sidebar() { | ... | @@ -4174,6 +4232,9 @@ export default function Sidebar() { |
| 4174 | const thread = threadByKeyRef.current.get(threadKey); | 4232 | const thread = threadByKeyRef.current.get(threadKey); |
| 4175 | return thread ? [thread] : []; | 4233 | return thread ? [thread] : []; |
| 4176 | }); | 4234 | }); |
| 4235 | const settlingThreads = selectedThreads.filter( | ||
| 4236 | (thread) => thread.settledOverride !== "settled", | ||
| 4237 | ); | ||
| 4177 | const canSnoozeSelection = selectedThreads.every( | 4238 | const canSnoozeSelection = selectedThreads.every( |
| 4178 | (thread) => | 4239 | (thread) => |
| 4179 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && | 4240 | serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true && |
| ... | @@ -4206,31 +4267,73 @@ export default function Sidebar() { | ... | @@ -4206,31 +4267,73 @@ export default function Sidebar() { |
| 4206 | const clicked = await settlePromise(() => | 4267 | const clicked = await settlePromise(() => |
| 4207 | api.contextMenu.show( | 4268 | api.contextMenu.show( |
| 4208 | [ | 4269 | [ |
| 4209 | ...(unpinMenuItem ? [unpinMenuItem] : []), | 4270 | ...(unpinMenuItem |
| 4210 | { id: "settle", label: `Settle (${count})` }, | 4271 | ? [{ ...unpinMenuItem, disabled: !canOperateThreads(pinnedSelectedThreads) }] |
| 4272 | : []), | ||
| 4273 | { | ||
| 4274 | id: "settle", | ||
| 4275 | label: `Settle (${count})`, | ||
| 4276 | disabled: settlingThreads.length === 0 || !canOperateThreads(settlingThreads), | ||
| 4277 | }, | ||
| 4211 | ...(canSnoozeSelection | 4278 | ...(canSnoozeSelection |
| 4212 | ? [ | 4279 | ? [ |
| 4213 | { | 4280 | { |
| 4214 | id: "snooze", | 4281 | id: "snooze", |
| 4215 | label: `Snooze (${count})`, | 4282 | label: `Snooze (${count})`, |
| 4283 | disabled: !canOperateThreads(selectedThreads), | ||
| 4216 | children: [ | 4284 | children: [ |
| 4217 | ...snoozePresets.map((preset) => ({ | 4285 | ...snoozePresets.map((preset) => ({ |
| 4218 | id: `snooze:${preset.id}`, | 4286 | id: `snooze:${preset.id}`, |
| 4219 | label: `${preset.label} (${preset.whenLabel})`, | 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 | { id: "mark-unread", label: `Mark unread (${count})` }, | 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 | position, | 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 | if (clicked.value?.startsWith("snooze:")) { | 4337 | if (clicked.value?.startsWith("snooze:")) { |
| 4235 | const preset = | 4338 | const preset = |
| 4236 | clicked.value === "snooze:custom" | 4339 | clicked.value === "snooze:custom" |
| ... | @@ -4303,7 +4406,11 @@ export default function Sidebar() { | ... | @@ -4303,7 +4406,11 @@ export default function Sidebar() { |
| 4303 | return; | 4406 | return; |
| 4304 | } | 4407 | } |
| 4305 | if (clicked.value === "settle") { | 4408 | if (clicked.value === "settle") { |
| 4306 | settleThreads(threadKeys); | 4409 | settleThreads( |
| 4410 | actionTargets.map((thread) => | ||
| 4411 | scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 4412 | ), | ||
| 4413 | ); | ||
| 4307 | clearSelection(); | 4414 | clearSelection(); |
| 4308 | return; | 4415 | return; |
| 4309 | } | 4416 | } |
| ... | @@ -4328,6 +4435,15 @@ export default function Sidebar() { | ... | @@ -4328,6 +4435,15 @@ export default function Sidebar() { |
| 4328 | ); | 4435 | ); |
| 4329 | if (confirmed._tag === "Failure" || !confirmed.value) return; | 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 | const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ | 4447 | const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ |
| 4332 | entries: threadKeys.map((threadKey) => ({ threadKey })), | 4448 | entries: threadKeys.map((threadKey) => ({ threadKey })), |
| 4333 | delete: async ({ threadKey }, deletedThreadKeys) => { | 4449 | delete: async ({ threadKey }, deletedThreadKeys) => { |
| ... | @@ -4469,6 +4585,10 @@ export default function Sidebar() { | ... | @@ -4469,6 +4585,10 @@ export default function Sidebar() { |
| 4469 | const clicked = await settlePromise(() => | 4585 | const clicked = await settlePromise(() => |
| 4470 | api.contextMenu.show( | 4586 | api.contextMenu.show( |
| 4471 | buildThreadActionMenuItems({ | 4587 | buildThreadActionMenuItems({ |
| 4588 | canOperate: readEnvironmentScope( | ||
| 4589 | threadRef.environmentId, | ||
| 4590 | AuthOrchestrationOperateScope, | ||
| 4591 | ), | ||
| 4472 | branch: thread.branch ?? null, | 4592 | branch: thread.branch ?? null, |
| 4473 | projectFilter: threadProjectGroup | 4593 | projectFilter: threadProjectGroup |
| 4474 | ? { | 4594 | ? { |
| ... | @@ -4495,7 +4615,8 @@ export default function Sidebar() { | ... | @@ -4495,7 +4615,8 @@ export default function Sidebar() { |
| 4495 | position, | 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 | if (clicked.value?.startsWith("snooze:")) { | 4620 | if (clicked.value?.startsWith("snooze:")) { |
| 4500 | const preset = | 4621 | const preset = |
| 4501 | clicked.value === "snooze:custom" | 4622 | clicked.value === "snooze:custom" |
| ... | @@ -5228,6 +5349,7 @@ export default function Sidebar() { | ... | @@ -5228,6 +5349,7 @@ export default function Sidebar() { |
| 5228 | key={threadKey} | 5349 | key={threadKey} |
| 5229 | id={threadKey} | 5350 | id={threadKey} |
| 5230 | contextDrag={isContextDrag} | 5351 | contextDrag={isContextDrag} |
| 5352 | environmentId={thread.environmentId} | ||
| 5231 | disabled={ | 5353 | disabled={ |
| 5232 | renamingThreadKey === threadKey || | 5354 | renamingThreadKey === threadKey || |
| 5233 | section === "working" || | 5355 | section === "working" || |
apps/web/src/components/chat/ChatHeader.tsx+30-7| ... | @@ -1,4 +1,8 @@ | ... | @@ -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 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 6 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 3 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | 7 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 4 | import { | 8 | import { |
| ... | @@ -23,6 +27,8 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; | ... | @@ -23,6 +27,8 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; |
| 23 | import { readLocalApi } from "~/localApi"; | 27 | import { readLocalApi } from "~/localApi"; |
| 24 | import { threadEnvironment } from "../../state/threads"; | 28 | import { threadEnvironment } from "../../state/threads"; |
| 25 | import { useAtomCommand } from "../../state/use-atom-command"; | 29 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 30 | import { useOrchestrationCommand } from "../../state/use-orchestration-command"; | ||
| 31 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | ||
| 26 | import { ProjectFavicon } from "../ProjectFavicon"; | 32 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 27 | import { | 33 | import { |
| 28 | WorkspaceBreadcrumb, | 34 | WorkspaceBreadcrumb, |
| ... | @@ -81,7 +87,11 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -81,7 +87,11 @@ export const ChatHeader = memo(function ChatHeader({ |
| 81 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), | 87 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), |
| 82 | [activeThreadEnvironmentId, activeThreadId], | 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 | reportFailure: false, | 95 | reportFailure: false, |
| 86 | }); | 96 | }); |
| 87 | // Inline rename, keyed by thread: navigating away drops an in-progress | 97 | // Inline rename, keyed by thread: navigating away drops an in-progress |
| ... | @@ -94,23 +104,36 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -94,23 +104,36 @@ export const ChatHeader = memo(function ChatHeader({ |
| 94 | } | null>(null); | 104 | } | null>(null); |
| 95 | if ( | 105 | if ( |
| 96 | renaming !== null && | 106 | renaming !== null && |
| 97 | (renaming.threadId !== activeThreadId || renaming.environmentId !== activeThreadEnvironmentId) | 107 | (renaming.threadId !== activeThreadId || |
| 108 | renaming.environmentId !== activeThreadEnvironmentId || | ||
| 109 | !canOperateThread) | ||
| 98 | ) { | 110 | ) { |
| 99 | setRenaming(null); | 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 | const renameCommittedRef = useRef(false); | 119 | const renameCommittedRef = useRef(false); |
| 103 | const startRename = useCallback(() => { | 120 | const startRename = useCallback(() => { |
| 121 | if ( | ||
| 122 | !isServerThread || | ||
| 123 | !readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope) | ||
| 124 | ) | ||
| 125 | return; | ||
| 104 | renameCommittedRef.current = false; | 126 | renameCommittedRef.current = false; |
| 105 | setRenaming({ | 127 | setRenaming({ |
| 106 | threadId: activeThreadId, | ||
| 107 | environmentId: activeThreadEnvironmentId, | 128 | environmentId: activeThreadEnvironmentId, |
| 129 | threadId: activeThreadId, | ||
| 108 | title: activeThreadTitle, | 130 | title: activeThreadTitle, |
| 109 | }); | 131 | }); |
| 110 | }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle]); | 132 | }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, isServerThread]); |
| 111 | const commitRename = useCallback( | 133 | const commitRename = useCallback( |
| 112 | (title: string) => { | 134 | (title: string) => { |
| 113 | setRenaming(null); | 135 | setRenaming(null); |
| 136 | if (!readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)) return; | ||
| 114 | const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); | 137 | const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); |
| 115 | if (resolution.action === "reject-empty") { | 138 | if (resolution.action === "reject-empty") { |
| 116 | toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); | 139 | toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); |
| ... | @@ -303,7 +326,7 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -303,7 +326,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 303 | aria-label={`Thread actions for ${activeThreadTitle}`} | 326 | aria-label={`Thread actions for ${activeThreadTitle}`} |
| 304 | aria-haspopup="menu" | 327 | aria-haspopup="menu" |
| 305 | onClick={openMenuFromTitle} | 328 | onClick={openMenuFromTitle} |
| 306 | onDoubleClick={handleTitleDoubleClick} | 329 | onDoubleClick={canOperateThread ? handleTitleDoubleClick : undefined} |
| 307 | onBlur={cancelPendingTitleMenu} | 330 | onBlur={cancelPendingTitleMenu} |
| 308 | 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" | 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,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 1 | /** | 2 | /** |
| 2 | * The comment half of the floating composer: a remark on the pull request itself, optionally | 3 | * The comment half of the floating composer: a remark on the pull request itself, optionally |
| 3 | * the one that closes or reopens it. The popover around it belongs to PullRequestComposer. | 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 +7,6 @@ import type { EnvironmentId, PullRequestDetailView, PullRequestRef } from "@t3to |
| 6 | import { SendIcon } from "lucide-react"; | 7 | import { SendIcon } from "lucide-react"; |
| 7 | import { useState, type RefObject } from "react"; | 8 | import { useState, type RefObject } from "react"; |
| 8 | 9 | ||
| 9 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 10 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 10 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 11 | 11 | ||
| 12 | import { Button } from "../ui/button"; | 12 | import { Button } from "../ui/button"; |
| ... | @@ -38,7 +38,9 @@ export function PullRequestCommentForm({ | ... | @@ -38,7 +38,9 @@ export function PullRequestCommentForm({ |
| 38 | }) { | 38 | }) { |
| 39 | const [body, setBody] = useState(""); | 39 | const [body, setBody] = useState(""); |
| 40 | const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); | 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 | const followUpAction = | 44 | const followUpAction = |
| 43 | detail.state === "open" && | 45 | detail.state === "open" && |
| 44 | detail.capabilities.actions.includes("close") && | 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,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 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); | 32 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); |
| 28 | vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); | 33 | vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); |
| 29 | vi.mock("~/state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map() })); | 34 | vi.mock("~/state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map() })); |
| ... | @@ -39,7 +44,7 @@ vi.mock("~/hooks/useSettings", () => ({ | ... | @@ -39,7 +44,7 @@ vi.mock("~/hooks/useSettings", () => ({ |
| 39 | vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} })); | 44 | vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} })); |
| 40 | vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => newThread })); | 45 | vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => newThread })); |
| 41 | vi.mock("~/lib/sourceControlActions", () => ({ | 46 | vi.mock("~/lib/sourceControlActions", () => ({ |
| 42 | usePreparePullRequestThreadAction: () => ({ run: prepareThread }), | 47 | usePreparePullRequestThreadAction: () => ({ run: prepareThread, isAllowed: true, error: null }), |
| 43 | })); | 48 | })); |
| 44 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); | 49 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); |
| 45 | vi.mock("~/state/pullRequests", async (importOriginal) => ({ | 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,6 +5,8 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 5 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; | 5 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 6 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 6 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { | 7 | import { |
| 8 | AuthOrchestrationOperateScope, | ||
| 9 | AuthSourceControlWriteScope, | ||
| 8 | type EnvironmentId, | 10 | type EnvironmentId, |
| 9 | type PullRequestAction, | 11 | type PullRequestAction, |
| 10 | type PullRequestMergeMethod, | 12 | type PullRequestMergeMethod, |
| ... | @@ -70,6 +72,7 @@ import type { ReviewCommentContext } from "~/reviewCommentContext"; | ... | @@ -70,6 +72,7 @@ import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 70 | import { useProjects, useServerConfigs } from "~/state/entities"; | 72 | import { useProjects, useServerConfigs } from "~/state/entities"; |
| 71 | import { useEnvironments } from "~/state/environments"; | 73 | import { useEnvironments } from "~/state/environments"; |
| 72 | import { useEnvironmentQuery } from "~/state/query"; | 74 | import { useEnvironmentQuery } from "~/state/query"; |
| 75 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | ||
| 73 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; | 76 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 74 | import { | 77 | import { |
| 75 | pullRequestEnvironment, | 78 | pullRequestEnvironment, |
| ... | @@ -578,6 +581,7 @@ export function PullRequestDetailPanel({ | ... | @@ -578,6 +581,7 @@ export function PullRequestDetailPanel({ |
| 578 | // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself | 581 | // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself |
| 579 | // alone. One at a time whatever the key: they all check the same pull request out. | 582 | // alone. One at a time whatever the key: they all check the same pull request out. |
| 580 | const [handoff, setHandoff] = useState<string | null>(null); | 583 | const [handoff, setHandoff] = useState<string | null>(null); |
| 584 | const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); | ||
| 581 | const detailQuery = useEnvironmentQuery( | 585 | const detailQuery = useEnvironmentQuery( |
| 582 | pullRequestEnvironment.detail({ environmentId, input: reference }), | 586 | pullRequestEnvironment.detail({ environmentId, input: reference }), |
| 583 | ); | 587 | ); |
| ... | @@ -690,6 +694,25 @@ export function PullRequestDetailPanel({ | ... | @@ -690,6 +694,25 @@ export function PullRequestDetailPanel({ |
| 690 | ? null | 694 | ? null |
| 691 | : { | 695 | : { |
| 692 | ...coreDetail, | 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 | author: activity?.author ?? coreDetail.author, | 716 | author: activity?.author ?? coreDetail.author, |
| 694 | reviewers: activity?.reviewers ?? coreDetail.reviewers, | 717 | reviewers: activity?.reviewers ?? coreDetail.reviewers, |
| 695 | comments: activity?.comments ?? [], | 718 | comments: activity?.comments ?? [], |
| ... | @@ -699,7 +722,7 @@ export function PullRequestDetailPanel({ | ... | @@ -699,7 +722,7 @@ export function PullRequestDetailPanel({ |
| 699 | commits: activity?.commits ?? [], | 722 | commits: activity?.commits ?? [], |
| 700 | reactions: activity?.reactions ?? [], | 723 | reactions: activity?.reactions ?? [], |
| 701 | }, | 724 | }, |
| 702 | [activity, coreDetail], | 725 | [activity, canWriteSourceControl, coreDetail], |
| 703 | ); | 726 | ); |
| 704 | const handoffSummary = detail ?? sharedSummary; | 727 | const handoffSummary = detail ?? sharedSummary; |
| 705 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 728 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| ... | @@ -857,8 +880,12 @@ export function PullRequestDetailPanel({ | ... | @@ -857,8 +880,12 @@ export function PullRequestDetailPanel({ |
| 857 | appliedForcedToken.current = forcedRefreshToken; | 880 | appliedForcedToken.current = forcedRefreshToken; |
| 858 | void refreshFromHost(); | 881 | void refreshFromHost(); |
| 859 | }, [forcedRefreshToken, refreshFromHost]); | 882 | }, [forcedRefreshToken, refreshFromHost]); |
| 860 | const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); | 883 | const runAction = useAtomCommand(pullRequestEnvironment.runAction, { |
| 861 | const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); | 884 | reportFailure: false, |
| 885 | }); | ||
| 886 | const postComment = useAtomCommand(pullRequestEnvironment.comment, { | ||
| 887 | reportFailure: false, | ||
| 888 | }); | ||
| 862 | // Which action is in flight, not merely that one is: every control here is disabled while any | 889 | // Which action is in flight, not merely that one is: every control here is disabled while any |
| 863 | // of them runs, but only the button that was pressed may say what it is doing. | 890 | // of them runs, but only the button that was pressed may say what it is doing. |
| 864 | const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null); | 891 | const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null); |
| ... | @@ -912,6 +939,7 @@ export function PullRequestDetailPanel({ | ... | @@ -912,6 +939,7 @@ export function PullRequestDetailPanel({ |
| 912 | const actingEnvironmentId = acting?.environmentId ?? environmentId; | 939 | const actingEnvironmentId = acting?.environmentId ?? environmentId; |
| 913 | const checkoutRoot = | 940 | const checkoutRoot = |
| 914 | acting?.workspaceRoot ?? detail?.workspaceRoot ?? project?.workspaceRoot ?? null; | 941 | acting?.workspaceRoot ?? detail?.workspaceRoot ?? project?.workspaceRoot ?? null; |
| 942 | const canOperateThread = useEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope); | ||
| 915 | const prepareThread = usePreparePullRequestThreadAction({ | 943 | const prepareThread = usePreparePullRequestThreadAction({ |
| 916 | environmentId: actingEnvironmentId, | 944 | environmentId: actingEnvironmentId, |
| 917 | cwd: checkoutRoot, | 945 | cwd: checkoutRoot, |
| ... | @@ -973,13 +1001,13 @@ export function PullRequestDetailPanel({ | ... | @@ -973,13 +1001,13 @@ export function PullRequestDetailPanel({ |
| 973 | method?: PullRequestMergeMethod, | 1001 | method?: PullRequestMergeMethod, |
| 974 | updateMethod?: PullRequestUpdateMethod, | 1002 | updateMethod?: PullRequestUpdateMethod, |
| 975 | ) => { | 1003 | ) => { |
| 976 | if (pendingAction !== null) return false; | 1004 | if (!canWriteSourceControl || pendingAction !== null) return false; |
| 977 | setPendingAction(action); | 1005 | setPendingAction(action); |
| 978 | return finishAction(action, method, updateMethod); | 1006 | return finishAction(action, method, updateMethod); |
| 979 | }; | 1007 | }; |
| 980 | 1008 | ||
| 981 | const performCommentAction = async (body: string, action: "close" | "reopen") => { | 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 | setPendingAction(action); | 1011 | setPendingAction(action); |
| 984 | const commentResult = await postComment({ | 1012 | const commentResult = await postComment({ |
| 985 | environmentId, | 1013 | environmentId, |
| ... | @@ -999,7 +1027,7 @@ export function PullRequestDetailPanel({ | ... | @@ -999,7 +1027,7 @@ export function PullRequestDetailPanel({ |
| 999 | 1027 | ||
| 1000 | const saveTitle = async (next: string) => { | 1028 | const saveTitle = async (next: string) => { |
| 1001 | const title = next.trim(); | 1029 | const title = next.trim(); |
| 1002 | if (detail === null || titleSaving) return; | 1030 | if (!canWriteSourceControl || detail === null || titleSaving) return; |
| 1003 | if (title.length === 0 || title === detail.title) { | 1031 | if (title.length === 0 || title === detail.title) { |
| 1004 | setTitleScope(null); | 1032 | setTitleScope(null); |
| 1005 | return; | 1033 | return; |
| ... | @@ -1030,6 +1058,8 @@ export function PullRequestDetailPanel({ | ... | @@ -1030,6 +1058,8 @@ export function PullRequestDetailPanel({ |
| 1030 | }; | 1058 | }; |
| 1031 | 1059 | ||
| 1032 | const attachTarget = composerDraftTarget ?? null; | 1060 | const attachTarget = composerDraftTarget ?? null; |
| 1061 | const canPrepareWorktree = prepareThread.isAllowed && canOperateThread; | ||
| 1062 | const canFixFindings = attachTarget !== null || canPrepareWorktree; | ||
| 1033 | const handoffLabels = pullRequestHandoffLabels(attachTarget !== null); | 1063 | const handoffLabels = pullRequestHandoffLabels(attachTarget !== null); |
| 1034 | 1064 | ||
| 1035 | const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => { | 1065 | const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => { |
| ... | @@ -1158,6 +1188,18 @@ export function PullRequestDetailPanel({ | ... | @@ -1158,6 +1188,18 @@ export function PullRequestDetailPanel({ |
| 1158 | return; | 1188 | return; |
| 1159 | } | 1189 | } |
| 1160 | if (checkoutRoot === null) return; | 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 | setHandoff(kind); | 1203 | setHandoff(kind); |
| 1162 | // The menu closes on the press and takes its "Preparing..." label with it, so this is the | 1204 | // The menu closes on the press and takes its "Preparing..." label with it, so this is the |
| 1163 | // only thing answering for the checkout. It carries no timeout of its own: a loading toast | 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,7 +1570,7 @@ export function PullRequestDetailPanel({ |
| 1528 | <TooltipPopup>Check out this pull request</TooltipPopup> | 1570 | <TooltipPopup>Check out this pull request</TooltipPopup> |
| 1529 | </Tooltip> | 1571 | </Tooltip> |
| 1530 | <MenuPopup align="end" side="bottom"> | 1572 | <MenuPopup align="end" side="bottom"> |
| 1531 | <MenuItem onClick={() => startCheckout("worktree")}> | 1573 | <MenuItem disabled={!canPrepareWorktree} onClick={() => startCheckout("worktree")}> |
| 1532 | <GitBranchIcon className="mt-1 size-3.5 shrink-0 self-start" /> | 1574 | <GitBranchIcon className="mt-1 size-3.5 shrink-0 self-start" /> |
| 1533 | <span className="flex min-w-0 flex-col"> | 1575 | <span className="flex min-w-0 flex-col"> |
| 1534 | <span>In a separate worktree</span> | 1576 | <span>In a separate worktree</span> |
| ... | @@ -1537,7 +1579,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1537,7 +1579,7 @@ export function PullRequestDetailPanel({ |
| 1537 | </span> | 1579 | </span> |
| 1538 | </span> | 1580 | </span> |
| 1539 | </MenuItem> | 1581 | </MenuItem> |
| 1540 | <MenuItem onClick={() => startCheckout("local")}> | 1582 | <MenuItem disabled={!prepareThread.isAllowed} onClick={() => startCheckout("local")}> |
| 1541 | <FolderGit2Icon className="mt-1 size-3.5 shrink-0 self-start" /> | 1583 | <FolderGit2Icon className="mt-1 size-3.5 shrink-0 self-start" /> |
| 1542 | <span className="flex min-w-0 flex-col"> | 1584 | <span className="flex min-w-0 flex-col"> |
| 1543 | <span>In this repository</span> | 1585 | <span>In this repository</span> |
| ... | @@ -2016,7 +2058,10 @@ export function PullRequestDetailPanel({ | ... | @@ -2016,7 +2058,10 @@ export function PullRequestDetailPanel({ |
| 2016 | </span> | 2058 | </span> |
| 2017 | </span> | 2059 | </span> |
| 2018 | </MenuItem> | 2060 | </MenuItem> |
| 2019 | <MenuItem disabled={handoff !== null} onClick={startFixFindings}> | 2061 | <MenuItem |
| 2062 | disabled={handoff !== null || !canFixFindings} | ||
| 2063 | onClick={startFixFindings} | ||
| 2064 | > | ||
| 2020 | <HammerIcon className="size-3.5" /> | 2065 | <HammerIcon className="size-3.5" /> |
| 2021 | {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings} | 2066 | {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings} |
| 2022 | </MenuItem> | 2067 | </MenuItem> |
| ... | @@ -2383,7 +2428,9 @@ export function PullRequestDetailPanel({ | ... | @@ -2383,7 +2428,9 @@ export function PullRequestDetailPanel({ |
| 2383 | <Button | 2428 | <Button |
| 2384 | size="xs" | 2429 | size="xs" |
| 2385 | variant="outline" | 2430 | variant="outline" |
| 2386 | disabled={titleSaving || titleDraft.trim().length === 0} | 2431 | disabled={ |
| 2432 | !canWriteSourceControl || titleSaving || titleDraft.trim().length === 0 | ||
| 2433 | } | ||
| 2387 | onClick={() => void saveTitle(titleDraft)} | 2434 | onClick={() => void saveTitle(titleDraft)} |
| 2388 | > | 2435 | > |
| 2389 | {titleSaving ? "Saving..." : "Save"} | 2436 | {titleSaving ? "Saving..." : "Save"} |
| ... | @@ -2706,7 +2753,7 @@ export function PullRequestDetailPanel({ | ... | @@ -2706,7 +2753,7 @@ export function PullRequestDetailPanel({ |
| 2706 | pendingFinding={handoff} | 2753 | pendingFinding={handoff} |
| 2707 | fixFindingLabel={handoffLabels.fixFinding} | 2754 | fixFindingLabel={handoffLabels.fixFinding} |
| 2708 | fixCheckLabel={handoffLabels.fixCheck} | 2755 | fixCheckLabel={handoffLabels.fixCheck} |
| 2709 | onFixFinding={startFixFinding} | 2756 | {...(canFixFindings ? { onFixFinding: startFixFinding } : {})} |
| 2710 | onRefresh={refreshDetail} | 2757 | onRefresh={refreshDetail} |
| 2711 | onRefreshChecks={refreshFromHost} | 2758 | onRefreshChecks={refreshFromHost} |
| 2712 | /> | 2759 | /> |
| ... | @@ -2746,7 +2793,7 @@ export function PullRequestDetailPanel({ | ... | @@ -2746,7 +2793,7 @@ export function PullRequestDetailPanel({ |
| 2746 | onSelectedCommitChange={selectCodeCommit} | 2793 | onSelectedCommitChange={selectCodeCommit} |
| 2747 | pendingFinding={handoff} | 2794 | pendingFinding={handoff} |
| 2748 | fixFindingLabel={handoffLabels.fixFinding} | 2795 | fixFindingLabel={handoffLabels.fixFinding} |
| 2749 | onFixFinding={startFixFinding} | 2796 | {...(canFixFindings ? { onFixFinding: startFixFinding } : {})} |
| 2750 | onRefresh={refreshDetail} | 2797 | onRefresh={refreshDetail} |
| 2751 | refreshToken={codeRefreshToken} | 2798 | refreshToken={codeRefreshToken} |
| 2752 | /> | 2799 | /> |
apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx+4-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 1 | /** | 2 | /** |
| 2 | * Putting a label on, and taking one off, from the row that says which it already wears. | 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,7 +12,6 @@ import { useMemo, useState } from "react"; |
| 11 | 12 | ||
| 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 13 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 13 | import { useEnvironmentQuery } from "~/state/query"; | 14 | import { useEnvironmentQuery } from "~/state/query"; |
| 14 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 15 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 15 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 16 | 16 | ||
| 17 | import { toastManager } from "../ui/toast"; | 17 | import { toastManager } from "../ui/toast"; |
| ... | @@ -48,7 +48,9 @@ export function PullRequestLabelPicker({ | ... | @@ -48,7 +48,9 @@ export function PullRequestLabelPicker({ |
| 48 | const candidatesQuery = useEnvironmentQuery( | 48 | const candidatesQuery = useEnvironmentQuery( |
| 49 | open ? pullRequestEnvironment.labelCandidates({ environmentId, input: reference }) : null, | 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 | const candidates = useMemo( | 55 | const candidates = useMemo( |
| 54 | () => (candidatesQuery.data?.candidates ?? []).filter((entry) => matches(entry, query)), | 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,7 +1,12 @@ |
| 1 | import { useState } from "react"; | 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 | import { cn } from "~/lib/utils"; | 8 | import { cn } from "~/lib/utils"; |
| 9 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | ||
| 5 | 10 | ||
| 6 | import { Button } from "../ui/button"; | 11 | import { Button } from "../ui/button"; |
| 7 | import { Textarea } from "../ui/textarea"; | 12 | import { Textarea } from "../ui/textarea"; |
| ... | @@ -43,6 +48,7 @@ export function PullRequestMarkdownEditor({ | ... | @@ -43,6 +48,7 @@ export function PullRequestMarkdownEditor({ |
| 43 | readonly onSave: (next: string) => void; | 48 | readonly onSave: (next: string) => void; |
| 44 | readonly onCancel: () => void; | 49 | readonly onCancel: () => void; |
| 45 | }) { | 50 | }) { |
| 51 | const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); | ||
| 46 | const [draft, setDraft] = useState(value); | 52 | const [draft, setDraft] = useState(value); |
| 47 | const [preview, setPreview] = useState(false); | 53 | const [preview, setPreview] = useState(false); |
| 48 | // The words this draft started from. React keeps a component instance wherever the same | 54 | // The words this draft started from. React keeps a component instance wherever the same |
| ... | @@ -55,7 +61,7 @@ export function PullRequestMarkdownEditor({ | ... | @@ -55,7 +61,7 @@ export function PullRequestMarkdownEditor({ |
| 55 | setDraft(value); | 61 | setDraft(value); |
| 56 | } | 62 | } |
| 57 | const empty = draft.trim().length === 0; | 63 | const empty = draft.trim().length === 0; |
| 58 | const saveDisabled = saving || (empty && !allowEmpty); | 64 | const saveDisabled = !canWriteSourceControl || saving || (empty && !allowEmpty); |
| 59 | 65 | ||
| 60 | return ( | 66 | return ( |
| 61 | <div | 67 | <div |
| ... | @@ -70,7 +76,12 @@ export function PullRequestMarkdownEditor({ | ... | @@ -70,7 +76,12 @@ export function PullRequestMarkdownEditor({ |
| 70 | ) { | 76 | ) { |
| 71 | event.preventDefault(); | 77 | event.preventDefault(); |
| 72 | event.stopPropagation(); | 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 | return; | 85 | return; |
| 75 | } | 86 | } |
| 76 | if (event.key !== "Escape" || saving) return; | 87 | if (event.key !== "Escape" || saving) return; |
| ... | @@ -119,7 +130,20 @@ export function PullRequestMarkdownEditor({ | ... | @@ -119,7 +130,20 @@ export function PullRequestMarkdownEditor({ |
| 119 | <Button size="xs" variant="ghost" disabled={saving} onClick={onCancel}> | 130 | <Button size="xs" variant="ghost" disabled={saving} onClick={onCancel}> |
| 120 | Cancel | 131 | Cancel |
| 121 | </Button> | 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 | {saving ? "Saving..." : "Save"} | 147 | {saving ? "Saving..." : "Save"} |
| 124 | </Button> | 148 | </Button> |
| 125 | </div> | 149 | </div> |
apps/web/src/components/pullRequest/PullRequestReactions.tsx+4-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 1 | import type { | 2 | import type { |
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 3 | PullRequestReaction, | 4 | PullRequestReaction, |
| ... | @@ -9,7 +10,6 @@ import { useState } from "react"; | ... | @@ -9,7 +10,6 @@ import { useState } from "react"; |
| 9 | 10 | ||
| 10 | import { cn } from "~/lib/utils"; | 11 | import { cn } from "~/lib/utils"; |
| 11 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 12 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 13 | 13 | ||
| 14 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 14 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 15 | import { toastManager } from "../ui/toast"; | 15 | import { toastManager } from "../ui/toast"; |
| ... | @@ -58,7 +58,9 @@ export function PullRequestReactionBar({ | ... | @@ -58,7 +58,9 @@ export function PullRequestReactionBar({ |
| 58 | readonly signature: string; | 58 | readonly signature: string; |
| 59 | readonly values: ReadonlyMap<PullRequestReactionContent, boolean>; | 59 | readonly values: ReadonlyMap<PullRequestReactionContent, boolean>; |
| 60 | }>({ signature: "", values: EMPTY_PENDING }); | 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 | const signature = reactionsSignature(reactions); | 65 | const signature = reactionsSignature(reactions); |
| 64 | const values = pending.signature === signature ? pending.values : EMPTY_PENDING; | 66 | const values = pending.signature === signature ? pending.values : EMPTY_PENDING; |
apps/web/src/components/pullRequest/PullRequestReviewForm.tsx+1-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 1 | /** | 2 | /** |
| 2 | * The review half of the floating composer: the summary and the verdict that sends it, together | 3 | * The review half of the floating composer: the summary and the verdict that sends it, together |
| 3 | * with whatever line comments the review is holding. The count of those lives on the composer's | 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,7 +10,6 @@ import { CheckIcon, MessageSquareIcon, XCircleIcon } from "lucide-react"; |
| 9 | import { useState, type ReactNode, type RefObject } from "react"; | 10 | import { useState, type ReactNode, type RefObject } from "react"; |
| 10 | 11 | ||
| 11 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 12 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 13 | 13 | ||
| 14 | import { Button } from "../ui/button"; | 14 | import { Button } from "../ui/button"; |
| 15 | import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select"; | 15 | import { Select, SelectItem, SelectPopup, SelectTrigger } from "../ui/select"; |
apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx+1-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 1 | /** | 2 | /** |
| 2 | * Asking someone to review, from the row that says who is already reviewing. | 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,7 +16,6 @@ import { useMemo, useState } from "react"; |
| 15 | 16 | ||
| 16 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 17 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 17 | import { useEnvironmentQuery } from "~/state/query"; | 18 | import { useEnvironmentQuery } from "~/state/query"; |
| 18 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 19 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 19 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 20 | 20 | ||
| 21 | import { toastManager } from "../ui/toast"; | 21 | import { toastManager } from "../ui/toast"; |
apps/web/src/components/pullRequest/PullRequestSpeedActions.tsx+6-1| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 1 | import type { PullRequestAction } from "@t3tools/contracts"; | 2 | import type { PullRequestAction } from "@t3tools/contracts"; |
| 3 | import { pullRequestEnvironment } from "~/state/pullRequests"; | ||
| 2 | import { useUiStateStore } from "~/uiStateStore"; | 4 | import { useUiStateStore } from "~/uiStateStore"; |
| 3 | import { Button } from "../ui/button"; | 5 | import { Button } from "../ui/button"; |
| 4 | import { Spinner } from "../ui/spinner"; | 6 | import { Spinner } from "../ui/spinner"; |
| ... | @@ -37,6 +39,9 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr | ... | @@ -37,6 +39,9 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr |
| 37 | sweeping?: boolean; | 39 | sweeping?: boolean; |
| 38 | onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void; | 40 | onCloseSweepStart?: (entry: Entry, event: PointerEvent) => void; |
| 39 | }) { | 41 | }) { |
| 42 | const canWrite = useAtomValue( | ||
| 43 | pullRequestEnvironment.runAction.permissionAtom(entry.environmentId), | ||
| 44 | ); | ||
| 40 | const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver( | 45 | const resolveProjectDefault = usePullRequestDefaultMergeMethodResolver( |
| 41 | entry.environmentId, | 46 | entry.environmentId, |
| 42 | entry.projectId, | 47 | entry.projectId, |
| ... | @@ -90,7 +95,7 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr | ... | @@ -90,7 +95,7 @@ export function PullRequestSpeedActions<Entry extends PullRequestSpeedActionEntr |
| 90 | <Button | 95 | <Button |
| 91 | variant={action === "close" ? "destructive-outline" : "outline"} | 96 | variant={action === "close" ? "destructive-outline" : "outline"} |
| 92 | size="xs" | 97 | size="xs" |
| 93 | disabled={busy || (action === "merge" && entry.stack !== undefined)} | 98 | disabled={!canWrite || busy || (action === "merge" && entry.stack !== undefined)} |
| 94 | aria-label={`${label} #${entry.number}`} | 99 | aria-label={`${label} #${entry.number}`} |
| 95 | onClick={() => void perform(action)} | 100 | onClick={() => void perform(action)} |
| 96 | onPointerDown={(event) => { | 101 | onPointerDown={(event) => { |
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+1-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 1 | import type { | 2 | import type { |
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 3 | PullRequestComment, | 4 | PullRequestComment, |
| ... | @@ -16,7 +17,6 @@ import { | ... | @@ -16,7 +17,6 @@ import { |
| 16 | } from "lucide-react"; | 17 | } from "lucide-react"; |
| 17 | import { useRef, useState, type ReactNode } from "react"; | 18 | import { useRef, useState, type ReactNode } from "react"; |
| 18 | 19 | ||
| 19 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 20 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 20 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 21 | import { cn } from "~/lib/utils"; | 21 | import { cn } from "~/lib/utils"; |
| 22 | import { useOpenLink } from "~/browser/useOpenLink"; | 22 | import { useOpenLink } from "~/browser/useOpenLink"; |
apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx+1-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 1 | import type { | 2 | import type { |
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 3 | PullRequestActor, | 4 | PullRequestActor, |
| ... | @@ -18,7 +19,6 @@ import { useState, type ReactNode } from "react"; | ... | @@ -18,7 +19,6 @@ import { useState, type ReactNode } from "react"; |
| 18 | import { cn } from "~/lib/utils"; | 19 | import { cn } from "~/lib/utils"; |
| 19 | import { readLocalApi } from "~/localApi"; | 20 | import { readLocalApi } from "~/localApi"; |
| 20 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 21 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 21 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 22 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 22 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 23 | 23 | ||
| 24 | import { Button } from "../ui/button"; | 24 | import { Button } from "../ui/button"; |
apps/web/src/components/pullRequest/usePullRequestActions.ts+4-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 1 | /** | 2 | /** |
| 2 | * The actions a pull request offers, extracted from the detail panel so smaller surfaces — the | 3 | * The actions a pull request offers, extracted from the detail panel so smaller surfaces — the |
| 3 | * thread details panel's pull request row — perform them through the very same code. Two callers | 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,7 +32,6 @@ import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 31 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; | 32 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 32 | import type { ReviewCommentContext } from "~/reviewCommentContext"; | 33 | import type { ReviewCommentContext } from "~/reviewCommentContext"; |
| 33 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 34 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 34 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 35 | 35 | ||
| 36 | import { toastManager } from "../ui/toast"; | 36 | import { toastManager } from "../ui/toast"; |
| 37 | import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic"; | 37 | import { handoffPrompt, handoffReviewComments, readableFailure } from "./pullRequestDetail.logic"; |
| ... | @@ -140,7 +140,9 @@ export function usePullRequestActionRunner({ | ... | @@ -140,7 +140,9 @@ export function usePullRequestActionRunner({ |
| 140 | /** Small surfaces resolve repository settings on the click, not for every visible row. */ | 140 | /** Small surfaces resolve repository settings on the click, not for every visible row. */ |
| 141 | resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; | 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 | const [actionPending, setActionPending] = useState(false); | 146 | const [actionPending, setActionPending] = useState(false); |
| 145 | const pendingRef = useRef(false); | 147 | const pendingRef = useRef(false); |
| 146 | 148 |
apps/web/src/components/settings/ConnectionsSettings.tsx+6| ... | @@ -31,6 +31,7 @@ import { | ... | @@ -31,6 +31,7 @@ import { |
| 31 | AuthRelayReadScope, | 31 | AuthRelayReadScope, |
| 32 | AuthRelayWriteScope, | 32 | AuthRelayWriteScope, |
| 33 | AuthReviewWriteScope, | 33 | AuthReviewWriteScope, |
| 34 | AuthSourceControlWriteScope, | ||
| 34 | AuthStandardClientScopes, | 35 | AuthStandardClientScopes, |
| 35 | AuthTerminalOperateScope, | 36 | AuthTerminalOperateScope, |
| 36 | type AuthClientSession, | 37 | type AuthClientSession, |
| ... | @@ -259,6 +260,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ | ... | @@ -259,6 +260,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ |
| 259 | title: "Use terminals", | 260 | title: "Use terminals", |
| 260 | description: "Create terminals and send input to running shells.", | 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 | scope: AuthReviewWriteScope, | 269 | scope: AuthReviewWriteScope, |
| 264 | title: "Write reviews", | 270 | title: "Write reviews", |
apps/web/src/components/threadActionMenu.logic.test.ts+52| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | } from "./threadActionMenu.logic"; | 7 | } from "./threadActionMenu.logic"; |
| 8 | 8 | ||
| 9 | const baseState: ThreadActionMenuState = { | 9 | const baseState: ThreadActionMenuState = { |
| 10 | canOperate: true, | ||
| 10 | branch: null, | 11 | branch: null, |
| 11 | projectFilter: null, | 12 | projectFilter: null, |
| 12 | isPinned: false, | 13 | isPinned: false, |
| ... | @@ -39,6 +40,57 @@ function allIds(state: ThreadActionMenuState): string[] { | ... | @@ -39,6 +40,57 @@ function allIds(state: ThreadActionMenuState): string[] { |
| 39 | } | 40 | } |
| 40 | 41 | ||
| 41 | describe("buildThreadActionMenuItems", () => { | 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 | it("hides lifecycle items when the environment lacks the capabilities", () => { | 94 | it("hides lifecycle items when the environment lacks the capabilities", () => { |
| 43 | expect( | 95 | expect( |
| 44 | ids({ | 96 | ids({ |
apps/web/src/components/threadActionMenu.logic.ts+28-1| ... | @@ -70,6 +70,7 @@ export function buildDraftActionMenuItems(options: { | ... | @@ -70,6 +70,7 @@ export function buildDraftActionMenuItems(options: { |
| 70 | } | 70 | } |
| 71 | 71 | ||
| 72 | export interface ThreadActionMenuState { | 72 | export interface ThreadActionMenuState { |
| 73 | readonly canOperate: boolean; | ||
| 73 | readonly branch: string | null; | 74 | readonly branch: string | null; |
| 74 | /** | 75 | /** |
| 75 | * Project scoping for the thread list. Null on surfaces with no scoped | 76 | * Project scoping for the thread list. Null on surfaces with no scoped |
| ... | @@ -100,6 +101,19 @@ export interface ThreadActionMenuState { | ... | @@ -100,6 +101,19 @@ export interface ThreadActionMenuState { |
| 100 | readonly snoozePresets: ReadonlyArray<SnoozePreset>; | 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 | * Single source for the per-thread action menu: the sidebar row's right-click | 118 | * Single source for the per-thread action menu: the sidebar row's right-click |
| 105 | * menu and the chat header menu share labels, ordering, and capability gating. | 119 | * menu and the chat header menu share labels, ordering, and capability gating. |
| ... | @@ -108,7 +122,7 @@ export interface ThreadActionMenuState { | ... | @@ -108,7 +122,7 @@ export interface ThreadActionMenuState { |
| 108 | export function buildThreadActionMenuItems( | 122 | export function buildThreadActionMenuItems( |
| 109 | state: ThreadActionMenuState, | 123 | state: ThreadActionMenuState, |
| 110 | ): ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> { | 124 | ): ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> { |
| 111 | return [ | 125 | const items: ReadonlyArray<ContextMenuItem<ThreadActionMenuId>> = [ |
| 112 | ...(state.branch | 126 | ...(state.branch |
| 113 | ? [ | 127 | ? [ |
| 114 | { | 128 | { |
| ... | @@ -235,4 +249,17 @@ export function buildThreadActionMenuItems( | ... | @@ -235,4 +249,17 @@ export function buildThreadActionMenuItems( |
| 235 | icon: "trash", | 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,18 +7,24 @@ import { |
| 7 | squashAtomCommandFailure, | 7 | squashAtomCommandFailure, |
| 8 | } from "@t3tools/client-runtime/state/runtime"; | 8 | } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; | 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 | import { useRouter } from "@tanstack/react-router"; | 15 | import { useRouter } from "@tanstack/react-router"; |
| 12 | import { useCallback, useMemo } from "react"; | 16 | import { useCallback, useMemo } from "react"; |
| 13 | 17 | ||
| 14 | import { resolveSnoozePresets } from "../components/Sidebar.snooze"; | 18 | import { resolveSnoozePresets } from "../components/Sidebar.snooze"; |
| 15 | import { | 19 | import { |
| 16 | buildThreadActionMenuItems, | 20 | buildThreadActionMenuItems, |
| 21 | threadActionRequiresOperate, | ||
| 17 | type ThreadActionMenuId, | 22 | type ThreadActionMenuId, |
| 18 | } from "../components/threadActionMenu.logic"; | 23 | } from "../components/threadActionMenu.logic"; |
| 19 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | 24 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; |
| 20 | import { threadEnvironment } from "../state/threads"; | 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 | import { | 28 | import { |
| 23 | readEnvironmentSupportsAutoSettleOptOut, | 29 | readEnvironmentSupportsAutoSettleOptOut, |
| 24 | readEnvironmentSupportsPinning, | 30 | readEnvironmentSupportsPinning, |
| ... | @@ -94,7 +100,7 @@ export function useThreadActionMenu(input: { | ... | @@ -94,7 +100,7 @@ export function useThreadActionMenu(input: { |
| 94 | deleteThread, | 100 | deleteThread, |
| 95 | markThreadUnread, | 101 | markThreadUnread, |
| 96 | } = useThreadActions(); | 102 | } = useThreadActions(); |
| 97 | const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { | 103 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { |
| 98 | reportFailure: false, | 104 | reportFailure: false, |
| 99 | }); | 105 | }); |
| 100 | const handleNewThread = useNewThreadHandler(); | 106 | const handleNewThread = useNewThreadHandler(); |
| ... | @@ -142,6 +148,7 @@ export function useThreadActionMenu(input: { | ... | @@ -142,6 +148,7 @@ export function useThreadActionMenu(input: { |
| 142 | const isRegeneratingTitle = thread.titleRegeneration != null; | 148 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 143 | const snoozePresets = resolveSnoozePresets(now, timestampFormat); | 149 | const snoozePresets = resolveSnoozePresets(now, timestampFormat); |
| 144 | const items = buildThreadActionMenuItems({ | 150 | const items = buildThreadActionMenuItems({ |
| 151 | canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), | ||
| 145 | branch: thread.branch ?? null, | 152 | branch: thread.branch ?? null, |
| 146 | projectFilter: null, | 153 | projectFilter: null, |
| 147 | isPinned: thread.pinnedAt != null, | 154 | isPinned: thread.pinnedAt != null, |
| ... | @@ -157,6 +164,16 @@ export function useThreadActionMenu(input: { | ... | @@ -157,6 +164,16 @@ export function useThreadActionMenu(input: { |
| 157 | const clicked = await settlePromise(() => api.contextMenu.show(items, position)); | 164 | const clicked = await settlePromise(() => api.contextMenu.show(items, position)); |
| 158 | if (clicked._tag === "Failure" || clicked.value === null) return; | 165 | if (clicked._tag === "Failure" || clicked.value === null) return; |
| 159 | const action: ThreadActionMenuId = clicked.value; | 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 | if (action.startsWith("snooze:")) { | 177 | if (action.startsWith("snooze:")) { |
| 161 | const preset = | 178 | const preset = |
| 162 | action === "snooze:custom" | 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 +7,14 @@ import { |
| 7 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 7 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 8 | import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; | 8 | import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| 9 | import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; | 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 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | 18 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 12 | import * as Cause from "effect/Cause"; | 19 | import * as Cause from "effect/Cause"; |
| 13 | import * as Schema from "effect/Schema"; | 20 | import * as Schema from "effect/Schema"; |
| ... | @@ -17,6 +24,7 @@ import { useCallback, useMemo, useRef } from "react"; | ... | @@ -17,6 +24,7 @@ import { useCallback, useMemo, useRef } from "react"; |
| 17 | 24 | ||
| 18 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; | 25 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; |
| 19 | import { useComposerDraftStore } from "../composerDraftStore"; | 26 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 27 | import { environmentSession, readEnvironmentScope } from "../state/session"; | ||
| 20 | import { terminalEnvironment } from "../state/terminal"; | 28 | import { terminalEnvironment } from "../state/terminal"; |
| 21 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 29 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 22 | import { environmentServerConfigsAtom } from "../state/server"; | 30 | import { environmentServerConfigsAtom } from "../state/server"; |
| ... | @@ -49,6 +57,8 @@ import { useClientSettings } from "./useSettings"; | ... | @@ -49,6 +57,8 @@ import { useClientSettings } from "./useSettings"; |
| 49 | import * as ThreadUndo from "./threadUndo"; | 57 | import * as ThreadUndo from "./threadUndo"; |
| 50 | import { showThreadUndoNotice } from "./showThreadUndoNotice"; | 58 | import { showThreadUndoNotice } from "./showThreadUndoNotice"; |
| 51 | import { useAtomCommand } from "../state/use-atom-command"; | 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 | export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()( | 63 | export class ThreadArchiveBlockedError extends Schema.TaggedError<ThreadArchiveBlockedError>()( |
| 54 | "ThreadArchiveBlockedError", | 64 | "ThreadArchiveBlockedError", |
| ... | @@ -243,49 +253,64 @@ export function useAcknowledgeThreadWoke() { | ... | @@ -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 | export function useThreadActions() { | 269 | export function useThreadActions() { |
| 247 | const closeTerminal = useAtomCommand(terminalEnvironment.close); | 270 | const archiveThreadMutation = useOrchestrationCommand(threadEnvironment.archive, { |
| 248 | const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { | ||
| 249 | reportFailure: false, | 271 | reportFailure: false, |
| 250 | }); | 272 | }); |
| 251 | const unarchiveThreadMutation = useAtomCommand(threadEnvironment.unarchive, { | 273 | const unarchiveThreadMutation = useOrchestrationCommand(threadEnvironment.unarchive, { |
| 252 | reportFailure: false, | 274 | reportFailure: false, |
| 253 | }); | 275 | }); |
| 254 | const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { | 276 | const deleteThreadMutation = useOrchestrationCommand(threadEnvironment.delete, { |
| 255 | reportFailure: false, | 277 | reportFailure: false, |
| 256 | }); | 278 | }); |
| 257 | const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { | 279 | const settleThreadMutation = useOrchestrationCommand(threadEnvironment.settle, { |
| 258 | reportFailure: false, | 280 | reportFailure: false, |
| 259 | }); | 281 | }); |
| 260 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { | 282 | const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { |
| 261 | reportFailure: false, | 283 | reportFailure: false, |
| 262 | }); | 284 | }); |
| 263 | const pinThreadMutation = useAtomCommand(threadEnvironment.pin, { | 285 | const pinThreadMutation = useOrchestrationCommand(threadEnvironment.pin, { |
| 264 | reportFailure: false, | 286 | reportFailure: false, |
| 265 | }); | 287 | }); |
| 266 | const unpinThreadMutation = useAtomCommand(threadEnvironment.unpin, { | 288 | const unpinThreadMutation = useOrchestrationCommand(threadEnvironment.unpin, { |
| 267 | reportFailure: false, | 289 | reportFailure: false, |
| 268 | }); | 290 | }); |
| 269 | const setThreadAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, { | 291 | const setThreadAutoSettleMutation = useOrchestrationCommand(threadEnvironment.setAutoSettle, { |
| 270 | reportFailure: false, | 292 | reportFailure: false, |
| 271 | }); | 293 | }); |
| 272 | const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { | 294 | const reorderPinnedThreadMutation = useOrchestrationCommand(threadEnvironment.reorderPin, { |
| 273 | reportFailure: false, | 295 | reportFailure: false, |
| 274 | }); | 296 | }); |
| 275 | const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, { | 297 | const reorderActiveThreadMutation = useOrchestrationCommand(threadEnvironment.reorderActive, { |
| 276 | reportFailure: false, | 298 | reportFailure: false, |
| 277 | }); | 299 | }); |
| 278 | const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { | 300 | const snoozeThreadMutation = useOrchestrationCommand(threadEnvironment.snooze, { |
| 279 | reportFailure: false, | 301 | reportFailure: false, |
| 280 | }); | 302 | }); |
| 281 | const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { | 303 | const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { |
| 282 | reportFailure: false, | 304 | reportFailure: false, |
| 283 | }); | 305 | }); |
| 284 | const markThreadUnread = useMarkThreadUnread(); | 306 | const markThreadUnread = useMarkThreadUnread(); |
| 285 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); | 307 | const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession); |
| 286 | const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { | 308 | const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { |
| 287 | reportFailure: false, | 309 | reportFailure: false, |
| 288 | }); | 310 | }); |
| 311 | const loadSessionState = useAtomQueryRunner(environmentSession.sessionStateAtom, { | ||
| 312 | reportFailure: false, | ||
| 313 | }); | ||
| 289 | const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { | 314 | const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { |
| 290 | reportFailure: false, | 315 | reportFailure: false, |
| 291 | }); | 316 | }); |
| ... | @@ -412,6 +437,8 @@ export function useThreadActions() { | ... | @@ -412,6 +437,8 @@ export function useThreadActions() { |
| 412 | 437 | ||
| 413 | const deleteThread = useCallback( | 438 | const deleteThread = useCallback( |
| 414 | async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => { | 439 | async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet<string> } = {}) => { |
| 440 | const permissionFailure = threadOperationFailure(target); | ||
| 441 | if (permissionFailure) return permissionFailure; | ||
| 415 | const resolved = resolveThreadTarget(target); | 442 | const resolved = resolveThreadTarget(target); |
| 416 | if (!resolved) { | 443 | if (!resolved) { |
| 417 | // Thread not in main store (e.g. archived thread) — dispatch delete directly. | 444 | // Thread not in main store (e.g. archived thread) — dispatch delete directly. |
| ... | @@ -456,13 +483,22 @@ export function useThreadActions() { | ... | @@ -456,13 +483,22 @@ export function useThreadActions() { |
| 456 | const environmentConfig = appAtomRegistry | 483 | const environmentConfig = appAtomRegistry |
| 457 | .get(environmentServerConfigsAtom) | 484 | .get(environmentServerConfigsAtom) |
| 458 | .get(threadRef.environmentId); | 485 | .get(threadRef.environmentId); |
| 459 | // A Scratch thread's folder is not a git worktree, and deleting the | 486 | const localApi = readLocalApi(); |
| 460 | // thread keeps its files. | 487 | let canDeleteWorktree = false; |
| 461 | const canDeleteWorktree = | 488 | if ( |
| 462 | orphanedWorktreePath !== null && | 489 | orphanedWorktreePath !== null && |
| 463 | threadProject !== null && | 490 | threadProject !== null && |
| 464 | !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot); | 491 | !isScratchProject(threadProject, environmentConfig?.scratchWorkspaceRoot) && |
| 465 | const localApi = readLocalApi(); | 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 | let shouldDeleteWorktree = false; | 502 | let shouldDeleteWorktree = false; |
| 467 | const environmentSettings = environmentConfig?.settings; | 503 | const environmentSettings = environmentConfig?.settings; |
| 468 | const automaticWorktreeCleanup = environmentSettings | 504 | const automaticWorktreeCleanup = environmentSettings |
| ... | @@ -493,11 +529,6 @@ export function useThreadActions() { | ... | @@ -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 | const deletedThreadIds = deletedIds ?? new Set<ThreadId>(); | 532 | const deletedThreadIds = deletedIds ?? new Set<ThreadId>(); |
| 502 | const currentRouteThreadRef = getCurrentRouteThreadRef(); | 533 | const currentRouteThreadRef = getCurrentRouteThreadRef(); |
| 503 | const shouldNavigateToFallback = | 534 | const shouldNavigateToFallback = |
| ... | @@ -546,14 +577,26 @@ export function useThreadActions() { | ... | @@ -546,14 +577,26 @@ export function useThreadActions() { |
| 546 | return deleteResult; | 577 | return deleteResult; |
| 547 | } | 578 | } |
| 548 | 579 | ||
| 549 | const removeResult = await removeWorktree({ | 580 | const removeResult = readEnvironmentScope( |
| 550 | environmentId: threadRef.environmentId, | 581 | threadRef.environmentId, |
| 551 | input: { | 582 | AuthSourceControlWriteScope, |
| 552 | cwd: threadProject.workspaceRoot, | 583 | ) |
| 553 | path: orphanedWorktreePath, | 584 | ? await removeWorktree({ |
| 554 | force: true, | 585 | environmentId: threadRef.environmentId, |
| 555 | }, | 586 | input: { |
| 556 | }); | 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 | const refreshResult = | 600 | const refreshResult = |
| 558 | removeResult._tag === "Success" | 601 | removeResult._tag === "Success" |
| 559 | ? await refreshVcsStatus({ | 602 | ? await refreshVcsStatus({ |
| ... | @@ -597,9 +640,9 @@ export function useThreadActions() { | ... | @@ -597,9 +640,9 @@ export function useThreadActions() { |
| 597 | clearComposerDraftForThread, | 640 | clearComposerDraftForThread, |
| 598 | clearProjectDraftThreadById, | 641 | clearProjectDraftThreadById, |
| 599 | clearTerminalUiState, | 642 | clearTerminalUiState, |
| 600 | closeTerminal, | ||
| 601 | deleteThreadMutation, | 643 | deleteThreadMutation, |
| 602 | getCurrentRouteThreadRef, | 644 | getCurrentRouteThreadRef, |
| 645 | loadSessionState, | ||
| 603 | refreshVcsStatus, | 646 | refreshVcsStatus, |
| 604 | removeWorktree, | 647 | removeWorktree, |
| 605 | router, | 648 | router, |
| ... | @@ -797,6 +840,8 @@ export function useThreadActions() { | ... | @@ -797,6 +840,8 @@ export function useThreadActions() { |
| 797 | 840 | ||
| 798 | const confirmAndUnpinThread = useCallback( | 841 | const confirmAndUnpinThread = useCallback( |
| 799 | async (target: ScopedThreadRef) => { | 842 | async (target: ScopedThreadRef) => { |
| 843 | const permissionFailure = threadOperationFailure(target); | ||
| 844 | if (permissionFailure) return permissionFailure; | ||
| 800 | const localApi = readLocalApi(); | 845 | const localApi = readLocalApi(); |
| 801 | const resolved = resolveThreadTarget(target); | 846 | const resolved = resolveThreadTarget(target); |
| 802 | const confirmationResult = await requestThreadUnpinConfirmation({ | 847 | const confirmationResult = await requestThreadUnpinConfirmation({ |
| ... | @@ -930,6 +975,8 @@ export function useThreadActions() { | ... | @@ -930,6 +975,8 @@ export function useThreadActions() { |
| 930 | 975 | ||
| 931 | const confirmAndDeleteThread = useCallback( | 976 | const confirmAndDeleteThread = useCallback( |
| 932 | async (target: ScopedThreadRef) => { | 977 | async (target: ScopedThreadRef) => { |
| 978 | const permissionFailure = threadOperationFailure(target); | ||
| 979 | if (permissionFailure) return permissionFailure; | ||
| 933 | const localApi = readLocalApi(); | 980 | const localApi = readLocalApi(); |
| 934 | const resolved = resolveThreadTarget(target); | 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,6 +20,11 @@ const router = vi.hoisted(() => ({ |
| 20 | navigate: vi.fn(async () => {}), | 20 | navigate: vi.fn(async () => {}), |
| 21 | state: { matches: [{ params: {} as Record<string, string> }] }, | 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 | vi.mock("react", async (original) => ({ | 28 | vi.mock("react", async (original) => ({ |
| 24 | ...(await original<typeof import("react")>()), | 29 | ...(await original<typeof import("react")>()), |
| 25 | useCallback: (callback: unknown) => callback, | 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,12 +9,15 @@ import { |
| 9 | type VcsActionOperation, | 9 | type VcsActionOperation, |
| 10 | type RunVcsStackedActionInput, | 10 | type RunVcsStackedActionInput, |
| 11 | } from "@t3tools/client-runtime/state/vcs"; | 11 | } from "@t3tools/client-runtime/state/vcs"; |
| 12 | import type { | 12 | import { |
| 13 | EnvironmentId, | 13 | AuthOrchestrationOperateScope, |
| 14 | GitResolvePullRequestResult, | 14 | AuthSourceControlWriteScope, |
| 15 | SourceControlCloneProtocol, | 15 | EnvironmentAuthorizationError, |
| 16 | SourceControlRepositoryVisibility, | 16 | type EnvironmentId, |
| 17 | ThreadId, | 17 | type GitResolvePullRequestResult, |
| 18 | type SourceControlCloneProtocol, | ||
| 19 | type SourceControlRepositoryVisibility, | ||
| 20 | type ThreadId, | ||
| 18 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 19 | import * as Cause from "effect/Cause"; | 22 | import * as Cause from "effect/Cause"; |
| 20 | import * as Option from "effect/Option"; | 23 | import * as Option from "effect/Option"; |
| ... | @@ -24,6 +27,7 @@ import { useCallback } from "react"; | ... | @@ -24,6 +27,7 @@ import { useCallback } from "react"; |
| 24 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 27 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 25 | import { gitEnvironment } from "./git"; | 28 | import { gitEnvironment } from "./git"; |
| 26 | import { useEnvironmentQuery } from "./query"; | 29 | import { useEnvironmentQuery } from "./query"; |
| 30 | import { readEnvironmentScope, useEnvironmentScope } from "./session"; | ||
| 27 | import { sourceControlEnvironment } from "./sourceControl"; | 31 | import { sourceControlEnvironment } from "./sourceControl"; |
| 28 | import { useAtomCommand } from "./use-atom-command"; | 32 | import { useAtomCommand } from "./use-atom-command"; |
| 29 | import { vcsActionManager, vcsEnvironment } from "./vcs"; | 33 | import { vcsActionManager, vcsEnvironment } from "./vcs"; |
| ... | @@ -45,11 +49,15 @@ interface SourceControlActionState< | ... | @@ -45,11 +49,15 @@ interface SourceControlActionState< |
| 45 | R extends AtomCommandResult<unknown, unknown>, | 49 | R extends AtomCommandResult<unknown, unknown>, |
| 46 | > { | 50 | > { |
| 47 | readonly isPending: boolean; | 51 | readonly isPending: boolean; |
| 52 | readonly isAllowed: boolean; | ||
| 48 | readonly error: unknown; | 53 | readonly error: unknown; |
| 49 | readonly run: ( | 54 | readonly run: ( |
| 50 | ...args: TArgs | 55 | ...args: TArgs |
| 51 | ) => Promise< | 56 | ) => Promise< |
| 52 | AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R> | VcsActionUnavailableError> | 57 | AtomCommandResult< |
| 58 | AtomCommandSuccess<R>, | ||
| 59 | AtomCommandFailure<R> | VcsActionUnavailableError | EnvironmentAuthorizationError | ||
| 60 | > | ||
| 53 | >; | 61 | >; |
| 54 | readonly resetError: () => void; | 62 | readonly resetError: () => void; |
| 55 | } | 63 | } |
| ... | @@ -73,6 +81,7 @@ function useAction< | ... | @@ -73,6 +81,7 @@ function useAction< |
| 73 | readonly onSuccess?: () => void; | 81 | readonly onSuccess?: () => void; |
| 74 | readonly managedExternally?: boolean; | 82 | readonly managedExternally?: boolean; |
| 75 | }): SourceControlActionState<TArgs, R> { | 83 | }): SourceControlActionState<TArgs, R> { |
| 84 | const isAllowed = useEnvironmentScope(input.scope.environmentId, AuthSourceControlWriteScope); | ||
| 76 | const operation = ACTION_OPERATION[input.kind]; | 85 | const operation = ACTION_OPERATION[input.kind]; |
| 77 | const state = useAtomValue(vcsActionManager.stateAtom(input.scope)); | 86 | const state = useAtomValue(vcsActionManager.stateAtom(input.scope)); |
| 78 | const ownsState = state.operation === operation; | 87 | const ownsState = state.operation === operation; |
| ... | @@ -83,6 +92,19 @@ function useAction< | ... | @@ -83,6 +92,19 @@ function useAction< |
| 83 | 92 | ||
| 84 | const run = useCallback( | 93 | const run = useCallback( |
| 85 | async (...args: TArgs) => { | 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 | const execute = async (): Promise< | 108 | const execute = async (): Promise< |
| 87 | AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R>> | 109 | AtomCommandResult<AtomCommandSuccess<R>, AtomCommandFailure<R>> |
| 88 | > => { | 110 | > => { |
| ... | @@ -108,6 +130,7 @@ function useAction< | ... | @@ -108,6 +130,7 @@ function useAction< |
| 108 | ); | 130 | ); |
| 109 | 131 | ||
| 110 | return { | 132 | return { |
| 133 | isAllowed, | ||
| 111 | error: ownsState ? state.error : null, | 134 | error: ownsState ? state.error : null, |
| 112 | isPending: ownsState && state.isRunning, | 135 | isPending: ownsState && state.isRunning, |
| 113 | resetError, | 136 | resetError, |
| ... | @@ -307,6 +330,20 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop | ... | @@ -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 | return preparePullRequestThread({ | 347 | return preparePullRequestThread({ |
| 311 | environmentId: target.environmentId, | 348 | environmentId: target.environmentId, |
| 312 | input: { | 349 | input: { |
docs/user/remote-access.md+6| ... | @@ -281,6 +281,12 @@ Grouping checkouts does not combine their permissions. Shared project settings | ... | @@ -281,6 +281,12 @@ Grouping checkouts does not combine their permissions. Shared project settings |
| 281 | require `orchestration:operate` on every member environment; actions on one | 281 | require `orchestration:operate` on every member environment; actions on one |
| 282 | checkout use that checkout's permissions. | 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 | Settings changes, provider management, and environment maintenance can be granted | 290 | Settings changes, provider management, and environment maintenance can be granted |
| 285 | separately from access administration. New standard pairings include these | 291 | separately from access administration. New standard pairings include these |
| 286 | permissions. Existing clients keep their original grants after an update; to | 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,7 +203,7 @@ export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < |
| 203 | return yield* method(input).pipe(Effect.ensuring(completeObservation)); | 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 | tag: TTag, | 207 | tag: TTag, |
| 208 | input: EnvironmentRpcInput<TTag>, | 208 | input: EnvironmentRpcInput<TTag>, |
| 209 | ): Stream.Stream< | 209 | ): Stream.Stream< |
packages/client-runtime/src/state/commandPermissions.test.ts+43| ... | @@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; | ... | @@ -2,6 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { vi } from "vite-plus/test"; | 2 | import { vi } from "vite-plus/test"; |
| 3 | import { | 3 | import { |
| 4 | AuthOrchestrationOperateScope, | 4 | AuthOrchestrationOperateScope, |
| 5 | AuthSourceControlWriteScope, | ||
| 6 | ThreadId, | ||
| 5 | EnvironmentId, | 7 | EnvironmentId, |
| 6 | ScheduledTaskId, | 8 | ScheduledTaskId, |
| 7 | WS_METHODS, | 9 | WS_METHODS, |
| ... | @@ -150,3 +152,44 @@ describe("command permissions", () => { | ... | @@ -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,9 +1,14 @@ |
| 1 | import { createCommandPermissions } from "./commandPermissions.ts"; | ||
| 2 | import { RpcPermissionGuard } from "../rpc/client.ts"; | ||
| 3 | import { vi } from "vite-plus/test"; | ||
| 1 | import { | 4 | import { |
| 2 | EnvironmentId, | 5 | EnvironmentId, |
| 6 | AuthSourceControlWriteScope, | ||
| 3 | ProjectId, | 7 | ProjectId, |
| 4 | PullRequestOperationError, | 8 | PullRequestOperationError, |
| 5 | WS_METHODS, | 9 | WS_METHODS, |
| 6 | type PullRequestStack, | 10 | type PullRequestStack, |
| 11 | type AuthSessionState, | ||
| 7 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 8 | import { expect, it } from "@effect/vitest"; | 13 | import { expect, it } from "@effect/vitest"; |
| 9 | import * as Data from "effect/Data"; | 14 | import * as Data from "effect/Data"; |
| ... | @@ -194,6 +199,7 @@ for (const scenario of [ | ... | @@ -194,6 +199,7 @@ for (const scenario of [ |
| 194 | : route(WS_METHODS.pullRequestsRunAction, input) | 199 | : route(WS_METHODS.pullRequestsRunAction, input) |
| 195 | ).pipe( | 200 | ).pipe( |
| 196 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | 201 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 202 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | ||
| 197 | Effect.provideService(GitHubRoutingPermissions, { | 203 | Effect.provideService(GitHubRoutingPermissions, { |
| 198 | ...trustedRouting, | 204 | ...trustedRouting, |
| 199 | get: () => Effect.succeed(trusted ? "read-write" : "off"), | 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,6 +307,7 @@ it.effect("shares active read identity probes while keeping later reads and muta |
| 301 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | 307 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 302 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | 308 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 303 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | 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,6 +561,7 @@ it.effect.each(["github", "gitlab", "bitbucket", "azure-devops"] as const)( |
| 554 | }).pipe( | 561 | }).pipe( |
| 555 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | 562 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 556 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | 563 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 564 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | ||
| 557 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | 565 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 558 | ); | 566 | ); |
| 559 | const environment = provider === "github" ? "local" : "origin"; | 567 | const environment = provider === "github" ? "local" : "origin"; |
| ... | @@ -720,10 +728,12 @@ it.effect.each(["default", "origin-off", "destination-off", "read-only"] as cons | ... | @@ -720,10 +728,12 @@ it.effect.each(["default", "origin-off", "destination-off", "read-only"] as cons |
| 720 | }).pipe( | 728 | }).pipe( |
| 721 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | 729 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 722 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | 730 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 731 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | ||
| 723 | ); | 732 | ); |
| 724 | yield* permission === "default" | 733 | yield* permission === "default" |
| 725 | ? request | 734 | ? request |
| 726 | : request.pipe( | 735 | : request.pipe( |
| 736 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | ||
| 727 | Effect.provideService(GitHubRoutingPermissions, { | 737 | Effect.provideService(GitHubRoutingPermissions, { |
| 728 | ...trustedRouting, | 738 | ...trustedRouting, |
| 729 | get: (entry) => | 739 | get: (entry) => |
| ... | @@ -842,6 +852,7 @@ it.effect.each( | ... | @@ -842,6 +852,7 @@ it.effect.each( |
| 842 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | 852 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 843 | // This also represents a user enabling the stale catalog entry after re-resolution. | 853 | // This also represents a user enabling the stale catalog entry after re-resolution. |
| 844 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | 854 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 855 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | ||
| 845 | ); | 856 | ); |
| 846 | yield* stored === "unavailable" | 857 | yield* stored === "unavailable" |
| 847 | ? route | 858 | ? route |
| ... | @@ -897,6 +908,7 @@ it.live.each(["origin", "alternate"] as const)( | ... | @@ -897,6 +908,7 @@ it.live.each(["origin", "alternate"] as const)( |
| 897 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | 908 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 898 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | 909 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 899 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | 910 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 911 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | ||
| 900 | Effect.flip, | 912 | Effect.flip, |
| 901 | ); | 913 | ); |
| 902 | expect(error._tag).toBe("PullRequestOperationError"); | 914 | expect(error._tag).toBe("PullRequestOperationError"); |
| ... | @@ -958,6 +970,7 @@ it.effect.each( | ... | @@ -958,6 +970,7 @@ it.effect.each( |
| 958 | }).pipe( | 970 | }).pipe( |
| 959 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | 971 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 960 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | 972 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 973 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | ||
| 961 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | 974 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 962 | ); | 975 | ); |
| 963 | const fiber = yield* request.pipe(Effect.forkChild); | 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,6 +1027,7 @@ it.live("keeps source workspace metadata when an alternate answers a detail read |
| 1014 | }).pipe( | 1027 | }).pipe( |
| 1015 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | 1028 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 1016 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | 1029 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 1030 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | ||
| 1017 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | 1031 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 1018 | ); | 1032 | ); |
| 1019 | expect(result).toEqual({ | 1033 | expect(result).toEqual({ |
| ... | @@ -1101,6 +1115,7 @@ it.live( | ... | @@ -1101,6 +1115,7 @@ it.live( |
| 1101 | }).pipe( | 1115 | }).pipe( |
| 1102 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | 1116 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 1103 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | 1117 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 1118 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | ||
| 1104 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | 1119 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 1105 | ); | 1120 | ); |
| 1106 | }), | 1121 | }), |
| ... | @@ -1649,6 +1664,7 @@ it.effect.each([false, true])( | ... | @@ -1649,6 +1664,7 @@ it.effect.each([false, true])( |
| 1649 | }).pipe( | 1664 | }).pipe( |
| 1650 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), | 1665 | Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), |
| 1651 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), | 1666 | Effect.provideService(GitHubRoutingPermissions, trustedRouting), |
| 1667 | Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), | ||
| 1652 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | 1668 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), |
| 1653 | ); | 1669 | ); |
| 1654 | expect(result).toEqual({ state: "open", checks: [] }); | 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,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,7 +19,6 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| 19 | 19 | ||
| 20 | import { | 20 | import { |
| 21 | createAtomCommandScheduler, | 21 | createAtomCommandScheduler, |
| 22 | createEnvironmentCommand, | ||
| 23 | createEnvironmentRpcCommand, | 22 | createEnvironmentRpcCommand, |
| 24 | createEnvironmentRpcQueryAtomFamily, | 23 | createEnvironmentRpcQueryAtomFamily, |
| 25 | createEnvironmentRpcSubscriptionAtomFamily, | 24 | createEnvironmentRpcSubscriptionAtomFamily, |
| ... | @@ -320,7 +319,8 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -320,7 +319,8 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 320 | JSON.stringify([environmentId, input.projectId, input.repository, input.number]), | 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 | label: "environment-data:pull-requests:run-action", | 324 | label: "environment-data:pull-requests:run-action", |
| 325 | // Preparation belongs to the write's lane. Refreshable queries would restart it after | 325 | // Preparation belongs to the write's lane. Refreshable queries would restart it after |
| 326 | // every preceding action, and preparing outside the lane could reorder the clicks. | 326 | // every preceding action, and preparing outside the lane could reorder the clicks. |
| ... | @@ -328,8 +328,6 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -328,8 +328,6 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 328 | input: PullRequestActionInput & { | 328 | input: PullRequestActionInput & { |
| 329 | readonly resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; | 329 | readonly resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; |
| 330 | }, | 330 | }, |
| 331 | registry, | ||
| 332 | environmentId, | ||
| 333 | ) => | 331 | ) => |
| 334 | Effect.gen(function* () { | 332 | Effect.gen(function* () { |
| 335 | const { resolveMergeMethod, ...actionInput } = input; | 333 | const { resolveMergeMethod, ...actionInput } = input; |
| ... | @@ -369,9 +367,10 @@ export function createPullRequestEnvironmentAtoms<R, E>( | ... | @@ -369,9 +367,10 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 369 | }); | 367 | }); |
| 370 | preparedInput = { ...actionInput, mergeMethod }; | 368 | preparedInput = { ...actionInput, mergeMethod }; |
| 371 | } | 369 | } |
| 372 | yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput); | 370 | return yield* routedRequest(WS_METHODS.pullRequestsRunAction, preparedInput); |
| 373 | registry.refresh(preview({ environmentId, input: actionInput })); | ||
| 374 | }), | 371 | }), |
| 372 | onSuccess: ({ environmentId, input }, registry) => | ||
| 373 | Effect.sync(() => registry.refresh(preview({ environmentId, input }))), | ||
| 375 | scheduler: commandScheduler, | 374 | scheduler: commandScheduler, |
| 376 | concurrency: serialPerEnvironment, | 375 | concurrency: serialPerEnvironment, |
| 377 | }), | 376 | }), |
packages/client-runtime/src/state/runtime.ts+12-7| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { createCommandPermissions } from "./commandPermissions.ts"; | 1 | import { createCommandPermissions } from "./commandPermissions.ts"; |
| 2 | import { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; | 2 | import { followStreamInEnvironment } from "./environmentStreams.ts"; |
| 3 | export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; | 3 | export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; |
| 4 | import { | 4 | import { |
| 5 | type ClientGuardedRpcTag, | 5 | type ClientGuardedRpcTag, |
| ... | @@ -717,13 +717,18 @@ export function createEnvironmentRpcSubscriptionAtomFamily< | ... | @@ -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 | runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>, | 726 | runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>, |
| 722 | options: { | 727 | options: { |
| 723 | readonly label: string; | 728 | readonly label: string; |
| 724 | readonly tag: TTag; | 729 | readonly tag: TTag; |
| 725 | readonly execute?: ( | 730 | readonly execute?: ( |
| 726 | input: EnvironmentRpcInput<TTag>, | 731 | input: Input, |
| 727 | ) => Effect.Effect< | 732 | ) => Effect.Effect< |
| 728 | EnvironmentRpcSuccess<TTag>, | 733 | EnvironmentRpcSuccess<TTag>, |
| 729 | EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError, | 734 | EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError, |
| ... | @@ -732,19 +737,19 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary | ... | @@ -732,19 +737,19 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary |
| 732 | readonly scheduler?: AtomCommandScheduler; | 737 | readonly scheduler?: AtomCommandScheduler; |
| 733 | readonly concurrency?: AtomCommandConcurrency<{ | 738 | readonly concurrency?: AtomCommandConcurrency<{ |
| 734 | readonly environmentId: EnvironmentIdType; | 739 | readonly environmentId: EnvironmentIdType; |
| 735 | readonly input: EnvironmentRpcInput<TTag>; | 740 | readonly input: NoInfer<Input>; |
| 736 | }>; | 741 | }>; |
| 737 | readonly onSuccess?: ( | 742 | readonly onSuccess?: ( |
| 738 | target: { | 743 | target: { |
| 739 | readonly environmentId: EnvironmentIdType; | 744 | readonly environmentId: EnvironmentIdType; |
| 740 | readonly input: EnvironmentRpcInput<TTag>; | 745 | readonly input: NoInfer<Input>; |
| 741 | }, | 746 | }, |
| 742 | registry: AtomRegistry.AtomRegistry, | 747 | registry: AtomRegistry.AtomRegistry, |
| 743 | ) => Effect.Effect<void, never, R>; | 748 | ) => Effect.Effect<void, never, R>; |
| 744 | readonly onSettled?: ( | 749 | readonly onSettled?: ( |
| 745 | target: { | 750 | target: { |
| 746 | readonly environmentId: EnvironmentIdType; | 751 | readonly environmentId: EnvironmentIdType; |
| 747 | readonly input: EnvironmentRpcInput<TTag>; | 752 | readonly input: NoInfer<Input>; |
| 748 | }, | 753 | }, |
| 749 | registry: AtomRegistry.AtomRegistry, | 754 | registry: AtomRegistry.AtomRegistry, |
| 750 | ) => Effect.Effect<void, never, R>; | 755 | ) => Effect.Effect<void, never, R>; |
| ... | @@ -755,7 +760,7 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary | ... | @@ -755,7 +760,7 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary |
| 755 | label: options.label, | 760 | label: options.label, |
| 756 | ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), | 761 | ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), |
| 757 | ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), | 762 | ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), |
| 758 | execute: (input: EnvironmentRpcInput<TTag>, registry, environmentId) => { | 763 | execute: (input: Input, registry, environmentId) => { |
| 759 | const target = { | 764 | const target = { |
| 760 | environmentId, | 765 | environmentId, |
| 761 | input, | 766 | input, |
packages/client-runtime/src/state/sourceControl.test.ts+22| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import { vi } from "vite-plus/test"; | ||
| 1 | import { | 2 | import { |
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | AuthSourceControlWriteScope, | ||
| 3 | WS_METHODS, | 5 | WS_METHODS, |
| 4 | type SourceControlPublishRepositoryResult, | 6 | type SourceControlPublishRepositoryResult, |
| 5 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| ... | @@ -168,3 +170,23 @@ describe("source control environment atoms", () => { | ... | @@ -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,5 +1,7 @@ |
| 1 | import { vi } from "vite-plus/test"; | ||
| 1 | import { | 2 | import { |
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | AuthSourceControlWriteScope, | ||
| 3 | WS_METHODS, | 5 | WS_METHODS, |
| 4 | type VcsListRefsInput, | 6 | type VcsListRefsInput, |
| 5 | type VcsListRefsResult, | 7 | type VcsListRefsResult, |
| ... | @@ -643,3 +645,23 @@ describe("cached VCS refs", () => { | ... | @@ -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,5 +1,7 @@ |
| 1 | import { vi } from "vite-plus/test"; | ||
| 1 | import { | 2 | import { |
| 2 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | AuthSourceControlWriteScope, | ||
| 3 | ThreadId, | 5 | ThreadId, |
| 4 | ProjectId, | 6 | ProjectId, |
| 5 | WS_METHODS, | 7 | WS_METHODS, |
| ... | @@ -718,3 +720,23 @@ describe("vcsActionState", () => { | ... | @@ -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,13 +19,14 @@ import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; |
| 19 | 19 | ||
| 20 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 20 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 21 | import * as Persistence from "../platform/persistence.ts"; | 21 | import * as Persistence from "../platform/persistence.ts"; |
| 22 | import { runStream } from "../rpc/client.ts"; | 22 | import { runStreamGuarded, RpcPermissionGuard } from "../rpc/client.ts"; |
| 23 | import { | 23 | import { |
| 24 | createRuntimeCommand, | 24 | createRuntimeCommand, |
| 25 | runStreamInEnvironment, | 25 | runStreamInEnvironment, |
| 26 | type AtomCommand, | 26 | type AtomCommand, |
| 27 | type AtomCommandResult, | 27 | type AtomCommandResult, |
| 28 | } from "./runtime.ts"; | 28 | } from "./runtime.ts"; |
| 29 | import { createCommandPermissions } from "./commandPermissions.ts"; | ||
| 29 | import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; | 30 | import { vcsCommandScheduler } from "./vcsCommandScheduler.ts"; |
| 30 | import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts"; | 31 | import { invalidateCachedVcsRefs } from "./vcsRefInvalidation.ts"; |
| 31 | 32 | ||
| ... | @@ -483,7 +484,7 @@ export function createVcsActionManager<R, E>( | ... | @@ -483,7 +484,7 @@ export function createVcsActionManager<R, E>( |
| 483 | return consumeVcsActionProgress( | 484 | return consumeVcsActionProgress( |
| 484 | runStreamInEnvironment( | 485 | runStreamInEnvironment( |
| 485 | target.environmentId, | 486 | target.environmentId, |
| 486 | runStream(WS_METHODS.gitRunStackedAction, rpcInput), | 487 | runStreamGuarded(WS_METHODS.gitRunStackedAction, rpcInput), |
| 487 | ), | 488 | ), |
| 488 | { | 489 | { |
| 489 | target, | 490 | target, |
| ... | @@ -507,6 +508,10 @@ export function createVcsActionManager<R, E>( | ... | @@ -507,6 +508,10 @@ export function createVcsActionManager<R, E>( |
| 507 | }), | 508 | }), |
| 508 | }, | 509 | }, |
| 509 | ).pipe( | 510 | ).pipe( |
| 511 | Effect.provideService(RpcPermissionGuard, { | ||
| 512 | authorize: (id, method, payload) => | ||
| 513 | createCommandPermissions(runtime, method).authorize(registry, id, payload), | ||
| 514 | }), | ||
| 510 | Effect.ensuring(invalidateCachedVcsRefs(registry, target)), | 515 | Effect.ensuring(invalidateCachedVcsRefs(registry, target)), |
| 511 | Effect.tap(() => clearOwnedState), | 516 | Effect.tap(() => clearOwnedState), |
| 512 | Effect.tapError((error) => | 517 | Effect.tapError((error) => |
packages/contracts/src/auth.ts+3| ... | @@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const; | ... | @@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const; |
| 84 | export const AuthProvidersManageScope = "providers:manage" as const; | 84 | export const AuthProvidersManageScope = "providers:manage" as const; |
| 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; | 85 | export const AuthEnvironmentMaintainScope = "environment:maintain" as const; |
| 86 | export const AuthTerminalOperateScope = "terminal:operate" as const; | 86 | export const AuthTerminalOperateScope = "terminal:operate" as const; |
| 87 | export const AuthSourceControlWriteScope = "source-control:write" as const; | ||
| 87 | export const AuthReviewWriteScope = "review:write" as const; | 88 | export const AuthReviewWriteScope = "review:write" as const; |
| 88 | export const AuthAccessReadScope = "access:read" as const; | 89 | export const AuthAccessReadScope = "access:read" as const; |
| 89 | export const AuthAccessWriteScope = "access:write" as const; | 90 | export const AuthAccessWriteScope = "access:write" as const; |
| ... | @@ -97,6 +98,7 @@ export const AuthEnvironmentScope = Schema.Literals([ | ... | @@ -97,6 +98,7 @@ export const AuthEnvironmentScope = Schema.Literals([ |
| 97 | AuthEnvironmentMaintainScope, | 98 | AuthEnvironmentMaintainScope, |
| 98 | AuthTerminalOperateScope, | 99 | AuthTerminalOperateScope, |
| 99 | AuthReviewWriteScope, | 100 | AuthReviewWriteScope, |
| 101 | AuthSourceControlWriteScope, | ||
| 100 | AuthAccessReadScope, | 102 | AuthAccessReadScope, |
| 101 | AuthAccessWriteScope, | 103 | AuthAccessWriteScope, |
| 102 | AuthRelayReadScope, | 104 | AuthRelayReadScope, |
| ... | @@ -114,6 +116,7 @@ export const AuthStandardClientScopes = [ | ... | @@ -114,6 +116,7 @@ export const AuthStandardClientScopes = [ |
| 114 | AuthEnvironmentMaintainScope, | 116 | AuthEnvironmentMaintainScope, |
| 115 | AuthTerminalOperateScope, | 117 | AuthTerminalOperateScope, |
| 116 | AuthReviewWriteScope, | 118 | AuthReviewWriteScope, |
| 119 | AuthSourceControlWriteScope, | ||
| 117 | AuthRelayReadScope, | 120 | AuthRelayReadScope, |
| 118 | ] as const; | 121 | ] as const; |
| 119 | export const AuthAdministrativeScopes = [ | 122 | export const AuthAdministrativeScopes = [ |
packages/contracts/src/clientRpcPermissions.ts+40-2| ... | @@ -1,8 +1,39 @@ | ... | @@ -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 | import { WS_METHODS } from "./rpc.ts"; | 8 | import { WS_METHODS } from "./rpc.ts"; |
| 3 | 9 | ||
| 4 | /** Incremental client enforcement; the server still authorizes every request. */ | 10 | /** Incremental client enforcement; the server still authorizes every request. */ |
| 5 | export const CLIENT_GUARDED_RPC_SCOPES = { | 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 | [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, | 37 | [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, |
| 7 | [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, | 38 | [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, |
| 8 | [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, | 39 | [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, |
| ... | @@ -11,10 +42,17 @@ export const CLIENT_GUARDED_RPC_SCOPES = { | ... | @@ -11,10 +42,17 @@ export const CLIENT_GUARDED_RPC_SCOPES = { |
| 11 | } as const; | 42 | } as const; |
| 12 | export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; | 43 | export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; |
| 13 | 44 | ||
| 45 | const decodePrepareThread = Schema.decodeUnknownSync(GitPreparePullRequestThreadInput); | ||
| 46 | |||
| 14 | export function clientRpcRequiredScopes( | 47 | export function clientRpcRequiredScopes( |
| 15 | method: string, | 48 | method: string, |
| 16 | _input: unknown, | 49 | input: unknown, |
| 17 | ): readonly AuthEnvironmentScope[] { | 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 | return Object.hasOwn(CLIENT_GUARDED_RPC_SCOPES, method) | 56 | return Object.hasOwn(CLIENT_GUARDED_RPC_SCOPES, method) |
| 19 | ? [CLIENT_GUARDED_RPC_SCOPES[method as ClientGuardedRpcTag]] | 57 | ? [CLIENT_GUARDED_RPC_SCOPES[method as ClientGuardedRpcTag]] |
| 20 | : []; | 58 | : []; |