Files
remanso/src/hooks/useGitHubContent.hook.ts
Julien Calixte b8c9d07930
All checks were successful
CI / verify (push) Successful in 1m13s
feat(notes): auto-merge non-overlapping edits on conflict
When a save hits a GitHub conflict, try a three-way merge first: if our
edits and the remote ones don't overlap, commit the result silently with
a tailored toast. Only genuinely overlapping edits open the conflict
modal. Adds an optional successMessage override to updateFile.
2026-06-30 00:27:07 +02:00

138 lines
3.6 KiB
TypeScript

import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { confirmMessage, errorMessage } from "@/utils/notif"
const isConflictStatus = (status: number) => status === 409 || status === 422
const isUnauthorizedStatus = (status: number | undefined) => status === 401
export type FetchShaResult =
| { kind: "ok"; sha: string | null }
| { kind: "unauthorized" }
| { kind: "offline" }
export const useGitHubContent = ({
user,
repo
}: {
user: string
repo: string
}) => {
const fetchLatestSha = async (path: string): Promise<FetchShaResult> => {
try {
const response = await runWithAuthRetry((octokit) =>
octokit.request("GET /repos/{owner}/{repo}/contents/{+path}", {
owner: user,
repo,
path,
headers: { "X-GitHub-Api-Version": "2026-03-10" }
})
)
const data = response?.data
if (Array.isArray(data) || !data) return { kind: "ok", sha: null }
return { kind: "ok", sha: "sha" in data ? data.sha : null }
} catch (error) {
const status = (error as { status?: number })?.status
if (isUnauthorizedStatus(status)) return { kind: "unauthorized" }
return { kind: "offline" }
}
}
const putRaw = async ({
contentBase64,
path,
sha,
message,
successMessage,
conflictMessage,
failureMessage
}: {
contentBase64: string
path: string
sha?: string
message: string
successMessage: string
conflictMessage: string
failureMessage: string
}): Promise<{ sha: string | null; conflict: boolean }> => {
try {
const octokit = await getOctokit()
const response = await octokit.request(
"PUT /repos/{owner}/{repo}/contents/{+path}",
{
owner: user,
repo,
path,
message,
content: contentBase64,
sha
}
)
confirmMessage(successMessage)
return { sha: response?.data.content?.sha ?? null, conflict: false }
} catch (error) {
const status = (error as { status?: number })?.status
if (status && isConflictStatus(status)) {
errorMessage(conflictMessage)
console.warn(error)
return { sha: null, conflict: true }
}
errorMessage(failureMessage)
console.warn(error)
return { sha: null, conflict: false }
}
}
const putFile = async ({
content,
path,
sha,
successMessage = "✅ Note saved"
}: {
content: string
path: string
sha?: string
successMessage?: string
}): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({
contentBase64: encodeUTF8ToBase64(content),
path,
sha,
message: `Updating ${path} from Remanso`,
successMessage,
conflictMessage: "⚠ Conflict: this note changed on GitHub",
failureMessage: "❌ Note could not be saved"
})
const uploadBinaryFile = async ({
base64,
path
}: {
base64: string
path: string
}): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({
contentBase64: base64,
path,
message: `Uploading ${path} from Remanso`,
successMessage: "✅ Image uploaded",
conflictMessage: "⚠ A file already exists at this path on GitHub",
failureMessage: "❌ Image could not be uploaded"
})
return {
fetchLatestSha,
updateFile: async (props: {
content: string
path: string
sha: string
successMessage?: string
}) => putFile(props),
createFile: async (props: { content: string; path: string }) =>
putFile(props),
uploadBinaryFile
}
}