| 1 | import { usePullRequestConnected } from "./usePullRequestConnected"; |
| 2 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 4 | import type { PullRequestDetail } from "@t3tools/contracts"; |
| 5 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 6 | import { composerPullRequests } from "../../state/pull-requests"; |
| 7 | import { useEnvironmentQuery } from "../../state/query"; |
| 8 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 9 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 10 | import { pullRequestReviewKey } from "./pullRequestReviewDrafts"; |
| 11 | |
| 12 | export function usePullRequestFilesViewed(params: PullRequestParams, pr: PullRequestDetail | null) { |
| 13 | const target = pullRequestTarget(params); |
| 14 | const scopeKey = pullRequestReviewKey(params); |
| 15 | const supported = Boolean(pr?.capabilities.viewedFiles); |
| 16 | const permission = useAtomValue( |
| 17 | composerPullRequests.setFilesViewed.permissionAtom(target.environmentId), |
| 18 | ); |
| 19 | const connected = usePullRequestConnected(params.environmentId); |
| 20 | const allowed = connected && permission; |
| 21 | const query = useEnvironmentQuery(supported ? composerPullRequests.filesViewed(target) : null); |
| 22 | const refresh = useAtomRefresh(composerPullRequests.filesViewed(target)); |
| 23 | const write = useAtomCommand(composerPullRequests.setFilesViewed, { reportFailure: false }); |
| 24 | const [state, setState] = useState<{ |
| 25 | key: string; |
| 26 | overlay: Record<string, boolean>; |
| 27 | error: string | null; |
| 28 | }>({ key: scopeKey, overlay: {}, error: null }); |
| 29 | const overlay = state.key === scopeKey ? state.overlay : {}; |
| 30 | const error = state.key === scopeKey ? state.error : null; |
| 31 | const setOverlay = useCallback( |
| 32 | (overlay: Record<string, boolean>) => |
| 33 | setState((current) => ({ |
| 34 | key: scopeKey, |
| 35 | overlay, |
| 36 | error: current.key === scopeKey ? current.error : null, |
| 37 | })), |
| 38 | [scopeKey], |
| 39 | ); |
| 40 | const setError = (error: string | null) => |
| 41 | setState((current) => ({ |
| 42 | key: scopeKey, |
| 43 | error, |
| 44 | overlay: current.key === scopeKey ? current.overlay : {}, |
| 45 | })); |
| 46 | const versions = useRef(new Map<string, number>()); |
| 47 | const pressed = useRef<Record<string, boolean>>({}); |
| 48 | const reads = useRef(query.data); |
| 49 | useEffect(() => { |
| 50 | reads.current = query.data; |
| 51 | }, [query.data]); |
| 52 | const awaitingRead = useRef(new Map<string, typeof query.data>()); |
| 53 | const scope = useRef(scopeKey); |
| 54 | useEffect(() => { |
| 55 | scope.current = scopeKey; |
| 56 | versions.current.clear(); |
| 57 | awaitingRead.current.clear(); |
| 58 | pressed.current = {}; |
| 59 | }, [scopeKey]); |
| 60 | useEffect(() => { |
| 61 | if (supported && pr?.headSha) refresh(); |
| 62 | }, [supported, pr?.headSha, refresh]); |
| 63 | useEffect(() => { |
| 64 | let changed = false; |
| 65 | const next = { ...pressed.current }; |
| 66 | for (const [path, from] of awaitingRead.current) { |
| 67 | if (from === query.data) continue; |
| 68 | delete next[path]; |
| 69 | awaitingRead.current.delete(path); |
| 70 | changed = true; |
| 71 | } |
| 72 | if (changed) { |
| 73 | pressed.current = next; |
| 74 | setOverlay(next); |
| 75 | } |
| 76 | }, [query.data, setOverlay]); |
| 77 | const isViewed = (path: string) => |
| 78 | overlay[path] ?? |
| 79 | query.data?.files.some((file) => file.path === path && file.state === "viewed") ?? |
| 80 | false; |
| 81 | const toggle = async (path: string) => { |
| 82 | if (!supported || !allowed || !query.data) return; |
| 83 | const viewed = !( |
| 84 | pressed.current[path] ?? |
| 85 | query.data.files.some((file) => file.path === path && file.state === "viewed") |
| 86 | ); |
| 87 | const version = (versions.current.get(path) ?? 0) + 1; |
| 88 | versions.current.set(path, version); |
| 89 | awaitingRead.current.delete(path); |
| 90 | pressed.current = { ...pressed.current, [path]: viewed }; |
| 91 | setOverlay(pressed.current); |
| 92 | setError(null); |
| 93 | const response = await write({ |
| 94 | ...target, |
| 95 | input: { ...target.input, files: [{ path, viewed }] }, |
| 96 | }); |
| 97 | if (scope.current !== scopeKey) return; |
| 98 | if (response._tag === "Failure" && versions.current.get(path) === version) { |
| 99 | const next = { ...pressed.current }; |
| 100 | delete next[path]; |
| 101 | pressed.current = next; |
| 102 | setOverlay(next); |
| 103 | const cause = squashAtomCommandFailure(response); |
| 104 | setError(cause instanceof Error ? cause.message : "Could not update viewed files."); |
| 105 | } |
| 106 | if (response._tag === "Success" && versions.current.get(path) === version) |
| 107 | awaitingRead.current.set(path, reads.current); |
| 108 | refresh(); |
| 109 | }; |
| 110 | return { |
| 111 | supported, |
| 112 | canToggle: supported && allowed && Boolean(query.data), |
| 113 | isViewed, |
| 114 | toggle, |
| 115 | error: error ?? query.error, |
| 116 | truncated: query.data?.truncated, |
| 117 | }; |
| 118 | } |